@cspeach/cli 1.1.34 → 1.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/app.js CHANGED
@@ -6,7 +6,7 @@ import { roleProps } from './role-props.js';
6
6
  import { IDLE_EXIT_WINDOW_MS } from './interrupt.js';
7
7
  import { Body } from './body.js';
8
8
  import { Footer } from './footer.js';
9
- import { ApprovalModal } from './approval-modal.js';
9
+ import { ApprovalModal, estimateApprovalModalRows } from './approval-modal.js';
10
10
  import { CommandPalette } from './command-palette.js';
11
11
  import { FilePalette } from './file-palette.js';
12
12
  import { nextResolutionStep, applySlashPick } from './line-resolution.js';
@@ -15,7 +15,7 @@ import { FooterLine } from './footer-line.js';
15
15
  import { ShortcutsSheet } from './widgets/shortcuts-sheet.js';
16
16
  import { wasPasted } from '../repl/paste-marker.js';
17
17
  import { turnStatusEmitter } from './turn-status-emitter.js';
18
- import { pickSlotOccupant, liveBudget, SLOT_ROWS_ESTIMATE, PALETTE_ROWS } from './live-area.js';
18
+ import { pickSlotOccupant, liveBudget, cardRowBudget, SLOT_ROWS_ESTIMATE, PALETTE_ROWS } from './live-area.js';
19
19
  import { Checklist } from './checklist.js';
20
20
  import { formatChecklist, isPlanFinished, CHECKLIST_MAX_ROWS } from '../renderer/checklist-format.js';
21
21
  import { listWorkspaceFileChoices } from '../repl/at-picker.js';
@@ -27,7 +27,7 @@ import { approvalEmitter } from './approval-emitter.js';
27
27
  import { useHoldRawMode } from './widgets/hold-raw-mode.js';
28
28
  import { SapStateContext, globalStore } from './sap-state-store.js';
29
29
  import { coachingPickerEmitter } from './coaching-picker-emitter.js';
30
- import { CoachingPicker } from './widgets/coaching-picker.js';
30
+ import { CoachingPicker, estimateCoachingPickerRows } from './widgets/coaching-picker.js';
31
31
  import { askQuestionEmitter } from './ask-question-emitter.js';
32
32
  import { AskCard, estimateAskCardRows } from './widgets/ask-card.js';
33
33
  import { ConfirmCard, estimateConfirmCardRows } from './widgets/confirm-card.js';
@@ -575,14 +575,24 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
575
575
  // contributes a fixed estimate. Q2 (ask) and C1 (confirm) replace theirs
576
576
  // with a real estimator right below this line. P2 replaces `checklistRows: 0`.
577
577
  let cardRows = occupant === null ? 0 : SLOT_ROWS_ESTIMATE[occupant];
578
- // Q2 — the ask card's full height (descriptions included, so shedding them
579
- // cannot oscillate; spec §2 fold order: descriptions drop before options).
578
+ const paletteRows = lineResolution ? PALETTE_ROWS : 0;
579
+ // Fix round 1 (review I1): the room a card may take beside one input line.
580
+ const askMaxRows = cardRowBudget({ rows, paletteRows });
581
+ // Q2 — the ask card's height after its own folds under askMaxRows (labels
582
+ // to 1 row, descriptions to 1 row, then none — askCardLayout). Computed
583
+ // with descriptions ON, so shedding them cannot oscillate.
580
584
  if (occupant === 'ask' && askQuestionPending)
581
- cardRows = estimateAskCardRows(askQuestionPending, true, uiWidth(columns));
585
+ cardRows = estimateAskCardRows(askQuestionPending, true, uiWidth(columns), askMaxRows);
582
586
  // C1 — the confirm card's real height (facts + choices) for the spec §2 cap.
583
587
  if (occupant === 'confirm' && confirmSlot.pending)
584
588
  cardRows = estimateConfirmCardRows(confirmSlot.pending, uiWidth(columns), rows - 6);
585
- const paletteRows = lineResolution ? PALETTE_ROWS : 0;
589
+ // Fix batch 2026-10-12 — "Pick if:" wraps; count every row.
590
+ if (occupant === 'coaching' && coachingPending)
591
+ cardRows = estimateCoachingPickerRows(coachingPending.params, uiWidth(columns));
592
+ // Fix batch 2026-10-12 — the approval card's risk reasons wrap; count every
593
+ // row (+1: the slot's top margin).
594
+ if (occupant === 'approval' && pendingApproval)
595
+ cardRows = 1 + estimateApprovalModalRows(pendingApproval, uiWidth(columns) - 2);
586
596
  // P2 — the checklist's WANTED height (header + rows); liveBudget folds it.
587
597
  const showChecklist = todos.length > 0 && !planClosed;
588
598
  const checklistWidth = uiWidth(columns);
@@ -597,7 +607,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
597
607
  const budget = liveBudget({ rows, checklistRows: checklistDesiredRows, cardRows, inputLines, paletteRows });
598
608
  let slot = null;
599
609
  if (occupant === 'approval' && pendingApproval) {
600
- slot = (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
610
+ slot = (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, width: uiWidth(columns) - 2, onApply: () => {
601
611
  pendingApproval.resolve({ approved: true });
602
612
  clearApproval(pendingApproval);
603
613
  }, onSkip: () => {
@@ -614,7 +624,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
614
624
  }
615
625
  else if (occupant === 'ask' && askQuestionPending) {
616
626
  const req = askQuestionPending;
617
- slot = (_jsx(AskCard, { request: req, width: uiWidth(columns), showDescriptions: budget.showDescriptions, onResolveV1: (result) => {
627
+ slot = (_jsx(AskCard, { request: req, width: uiWidth(columns), showDescriptions: budget.showDescriptions, maxRows: askMaxRows, onResolveV1: (result) => {
618
628
  if (!('questions' in req))
619
629
  req.resolve(result);
620
630
  clearAskIfCurrent(req);
@@ -16,6 +16,35 @@ import { verbLine, viewAffordance, toSourceLines } from '../approvals/op-labels.
16
16
  import { useReclaimRawStdin } from './widgets/reclaim-raw-stdin.js';
17
17
  import { getLook } from '../renderer/look.js';
18
18
  import { roleProps, inkChip, legacyInkColor } from './role-props.js';
19
+ import { wrapRows } from '../renderer/wrap-rows.js';
20
+ import { uiWidth } from '../renderer/ui-width.js';
21
+ /**
22
+ * Fix batch 2026-10-12 (Task 2): why the risk went up is the decision input,
23
+ * so the "Risk: … (raised: …)" text wraps onto up to this many rows (both
24
+ * looks) instead of being cut. The verb and meta rows stay one row (chrome).
25
+ */
26
+ export const RISK_MAX_ROWS = 4;
27
+ /** Border 2 + paddingX 2 on each side. */
28
+ const MODAL_CHROME_COLS = 6;
29
+ function riskText(effectiveRisk) {
30
+ const raised = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
31
+ return `Risk: ${effectiveRisk.level}${raised ? ` (raised: ${raised})` : ''}`;
32
+ }
33
+ /** The risk text as the rows it is drawn on at card width `width`. */
34
+ export function riskRows(effectiveRisk, width) {
35
+ return wrapRows(riskText(effectiveRisk), Math.max(1, width - MODAL_CHROME_COLS), RISK_MAX_ROWS);
36
+ }
37
+ /**
38
+ * The collapsed card's rendered height at `width` (the live-area budget; the
39
+ * App adds its own one-row top margin). Panels: border 2 + paddingY 2 + verb
40
+ * + meta? + risk rows + gap + keys. Legacy adds the "Write approval" title and
41
+ * the gap under it.
42
+ */
43
+ export function estimateApprovalModalRows(p, width = uiWidth()) {
44
+ const meta = p.change.package || p.transport ? 1 : 0;
45
+ const body = 1 + meta + riskRows(p.effectiveRisk, width).length + 2;
46
+ return 4 + body + (getLook() === 'panels' ? 0 : 2);
47
+ }
19
48
  // Legacy look: today's literal names (bytes unchanged while colour is on).
20
49
  function riskColor(level) {
21
50
  return level === 'high' ? 'red' : level === 'medium' ? 'yellow' : 'green';
@@ -35,7 +64,8 @@ function diffLineColor(line, panels) {
35
64
  export const BACK_GUARD_MS = 300;
36
65
  /** The review view's key line, always at its bottom: how to go back and how to answer from here. */
37
66
  export const viewKeysLine = (reviewKey) => `Esc or ${reviewKey}: back · a: approve · s: skip · c: cancel turn`;
38
- export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn }) {
67
+ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn, width }) {
68
+ const cardWidth = width ?? uiWidth();
39
69
  // BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
40
70
  // when an inquirer gate ran earlier in the turn (see import block).
41
71
  useReclaimRawStdin();
@@ -148,7 +178,8 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
148
178
  : '';
149
179
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [isDiff ? 'Diff' : aff.label, _jsx(Text, { color: quiet, children: where })] }), change.diff ? (isDiff ? (shownLines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, from + i)))) : (shownLines.map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(from + i + 1).padStart(4), " "] }), line] }, from + i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: viewKeysLine(aff.key) }) })] }));
150
180
  }
151
- const raisedLabel = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
181
+ // Pre-wrapped rows, never Ink soft-wrap (Wave-1 rule): every row is counted.
182
+ const risks = riskRows(effectiveRisk, cardWidth);
152
183
  // Meta line: Package · Transport — each segment only when genuinely known.
153
184
  // The handler resolves `transport` per op (see op-labels displayTransport:
154
185
  // create-family may fall back to the session transport because the create
@@ -170,11 +201,12 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
170
201
  keys.push('[s] Skip', '[c] Cancel turn');
171
202
  // Panels look — same content, keys and risk colour; restyled only. The
172
203
  // approval runs BEFORE snapshot/write/syntax check, so the card claims no
173
- // step has run. Legacy return below stays byte-for-byte unchanged.
204
+ // step has run. (Fix batch 2026-10-12: the owner lifted the legacy byte pin
205
+ // for the wrapped risk rows and the fixed card width.)
174
206
  if (getLook() === 'panels') {
175
207
  const risk = roleProps(riskRole(effectiveRisk.level));
176
208
  const tag = inkChip('APPROVAL');
177
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: risk.color, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...tag.props, children: tag.text }), _jsx(Text, { children: ' ' }), _jsx(Text, { ...(change.op === 'delete' ? roleProps('bad') : {}), bold: true, children: verb }), change.op === 'create' ? _jsx(Text, { ...roleProps('dim'), children: " (new object)" }) : null] }), metaSegs.length > 0 && _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: metaSegs.join(' · ') }), _jsxs(Text, { ...risk, wrap: "truncate-end", children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised: ${raisedLabel})` : ''] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: keys[0] }), _jsx(Text, { children: ' ' + keys.slice(1).join(' ') })] }) })] }));
209
+ return (_jsxs(Box, { flexDirection: "column", width: cardWidth, borderStyle: "round", borderColor: risk.color, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...tag.props, children: tag.text }), _jsx(Text, { children: ' ' }), _jsx(Text, { ...(change.op === 'delete' ? roleProps('bad') : {}), bold: true, children: verb }), change.op === 'create' ? _jsx(Text, { ...roleProps('dim'), children: " (new object)" }) : null] }), metaSegs.length > 0 && _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: metaSegs.join(' · ') }), risks.map((row, i) => _jsx(Text, { ...risk, wrap: "truncate-end", children: row }, `risk-${i}`)), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: keys[0] }), _jsx(Text, { children: ' ' + keys.slice(1).join(' ') })] }) })] }));
178
210
  }
179
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: legacyInkColor(riskColor(effectiveRisk.level)), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), _jsxs(Text, { color: legacyInkColor(riskColor(effectiveRisk.level)), wrap: "truncate-end", children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised: ${raisedLabel})` : ''] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
211
+ return (_jsxs(Box, { flexDirection: "column", width: cardWidth, borderStyle: "round", borderColor: legacyInkColor(riskColor(effectiveRisk.level)), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), risks.map((row, i) => (_jsx(Text, { color: legacyInkColor(riskColor(effectiveRisk.level)), wrap: "truncate-end", children: row }, `risk-${i}`))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
180
212
  }
@@ -23,7 +23,8 @@ export const INPUT_MAX_LINES = 6;
23
23
  * occupant, fed to liveBudget as `cardRows`. They cover the collapsed card.
24
24
  * A task that owns a card with a variable height replaces its entry at the
25
25
  * App call site with a real estimator (Q2: estimateAskCardRows, C1:
26
- * estimateConfirmCardRows).
26
+ * estimateConfirmCardRows, fix batch 2026-10-12: estimateApprovalModalRows
27
+ * and estimateCoachingPickerRows).
27
28
  * approval margin 1 + border 2 + padding 2 + 3 fact rows + margin 1 + keys 1
28
29
  * rewind margin 1 + border 2 + title 1 + margin 1 + more 2 + REWIND_VISIBLE_ROWS 9 + margin 1 + hint 1
29
30
  * timeline margin 1 + border 2 + title 1 + margin 1 + more 2 + TIMELINE_VISIBLE_ROWS 9
@@ -69,3 +70,15 @@ export function liveBudget(args) {
69
70
  const inputMaxLines = Math.max(1, Math.min(Math.max(1, args.inputLines), INPUT_MAX_LINES, remaining));
70
71
  return { checklistRows, inputMaxLines, showDescriptions: remaining >= 1 };
71
72
  }
73
+ /**
74
+ * Fix round 1 (review I1): the rows a card may take so the whole live area
75
+ * stays ≤ rows − 2 with one input line beside it — the cap less the status
76
+ * strip, the input border, the footer, an open palette, the checklist's one
77
+ * folded line and one input line. A card that can fold (AskCard) folds to
78
+ * fit this.
79
+ */
80
+ export function cardRowBudget(args) {
81
+ const cap = Math.max(0, args.rows - 2);
82
+ const fixed = STATUS_ROWS + INPUT_BORDER_ROWS + FOOTER_ROWS + Math.max(0, args.paletteRows);
83
+ return Math.max(1, cap - fixed - 1 - 1);
84
+ }
@@ -31,7 +31,9 @@ import { getGlyphs } from '../../renderer/glyphs.js';
31
31
  import { truncateToWidth, uiWidth } from '../../renderer/ui-width.js';
32
32
  import { roleProps, selectedProps } from '../role-props.js';
33
33
  import { sanitizeControlChars } from '../../renderer/sanitize.js';
34
+ import { wrapRows } from '../../renderer/wrap-rows.js';
34
35
  import { answerDisplayText } from '../ask-answer-text.js';
36
+ import { CardFrame } from './confirm-card.js';
35
37
  /** Option rows visible at once — the rest scroll. */
36
38
  export const VISIBLE_OPTION_ROWS = 5;
37
39
  /**
@@ -58,75 +60,26 @@ function fit(text, cells) {
58
60
  export const QUESTION_MAX_ROWS = 3;
59
61
  /** Context wraps too; it gets at least one row per line and at most this many in all (or its line count, if larger). */
60
62
  export const CONTEXT_MAX_ROWS = 6;
61
- const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
62
- /** The longest grapheme-whole prefix of `word` that fits `cells`. */
63
- function takeCells(word, cells) {
64
- let out = '';
65
- let used = 0;
66
- for (const { segment } of GRAPHEMES.segment(word)) {
67
- const w = stringWidth(segment);
68
- if (used + w > cells && out.length > 0)
69
- break;
70
- out += segment;
71
- used += w;
72
- if (used >= cells)
73
- break;
74
- }
75
- return out;
76
- }
77
- /**
78
- * Round 4 item 8b — word-wrap card text to `cells` per row, at most `maxRows`
79
- * rows (the last row then ends with the `more` glyph). A question cut to one
80
- * row hid the model's suggestion ("…Sugges…"); it now wraps instead.
81
- */
82
- export function wrapCardText(text, cells, maxRows) {
83
- const clean = sanitize(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
84
- const width = Math.max(1, cells);
85
- const indent = /^ */.exec(clean)[0].slice(0, Math.max(0, width - 1));
86
- const rows = [];
87
- let cur = indent;
88
- const curEmpty = () => cur.length === 0 || cur === indent;
89
- for (let word of clean.trim().split(/ +/).filter((w) => w.length > 0)) {
90
- while (stringWidth(word) > width) {
91
- if (!curEmpty()) {
92
- rows.push(cur);
93
- cur = '';
94
- }
95
- const head = takeCells(word, width - stringWidth(cur));
96
- rows.push(cur + head);
97
- cur = '';
98
- word = word.slice(head.length);
99
- }
100
- if (word.length === 0)
101
- continue;
102
- if (curEmpty())
103
- cur += word;
104
- else if (stringWidth(cur) + 1 + stringWidth(word) <= width)
105
- cur += ` ${word}`;
106
- else {
107
- rows.push(cur);
108
- cur = word;
109
- }
110
- }
111
- if (!curEmpty() || rows.length === 0)
112
- rows.push(cur);
113
- const max = Math.max(1, maxRows);
114
- if (rows.length <= max)
115
- return rows;
116
- const kept = rows.slice(0, max);
117
- kept[max - 1] = truncateToWidth(`${rows.slice(max - 1).join(' ')} `, width);
118
- return kept;
119
- }
120
- /** The question rows, with the multi-select suffix kept whole on the last row. */
121
- function questionRows(question, suffix, cells) {
63
+ /** Fix batch 2026-10-12 — an option label wraps onto at most this many rows. */
64
+ export const LABEL_MAX_ROWS = 2;
65
+ /** …and its description too. */
66
+ export const DESCRIPTION_MAX_ROWS = 2;
67
+ /** Descriptions sit under the label text: paddingX + "❯ 1. ". */
68
+ const DESCRIPTION_INDENT = ' ';
69
+ /** Task 4 — over budget the context folds to this many rows… */
70
+ export const CONTEXT_FOLDED_ROWS = 3;
71
+ /** …and then the question to this many. */
72
+ export const QUESTION_FOLDED_ROWS = 2;
73
+ /** The question rows (at most `maxRows`), with the multi-select suffix kept whole on the last row. */
74
+ function questionRows(question, suffix, cells, maxRows = QUESTION_MAX_ROWS) {
122
75
  if (!suffix)
123
- return wrapCardText(question, cells, QUESTION_MAX_ROWS);
124
- const rows = wrapCardText(question, cells, QUESTION_MAX_ROWS);
76
+ return wrapRows(question, cells, maxRows);
77
+ const rows = wrapRows(question, cells, maxRows);
125
78
  const last = rows[rows.length - 1];
126
79
  if (stringWidth(last) + stringWidth(suffix) <= cells) {
127
80
  rows[rows.length - 1] = last + suffix;
128
81
  }
129
- else if (rows.length < QUESTION_MAX_ROWS) {
82
+ else if (rows.length < maxRows) {
130
83
  rows.push(suffix.trimStart());
131
84
  }
132
85
  else {
@@ -134,21 +87,70 @@ function questionRows(question, suffix, cells) {
134
87
  }
135
88
  return rows;
136
89
  }
137
- /** Context rows: every line keeps at least one row; wrapping fills the rest of the budget. */
138
- function contextRows(context, cells) {
90
+ /**
91
+ * Context rows: every line keeps at least one row; wrapping fills the rest of
92
+ * the budget. Folded (`maxRows` below CONTEXT_MAX_ROWS, Task 4) the context
93
+ * takes that many rows at most: more lines than rows run together.
94
+ */
95
+ function contextRows(context, cells, maxRows = CONTEXT_MAX_ROWS) {
139
96
  if (!context)
140
97
  return [];
141
- const lines = context.split('\n');
142
- let budget = Math.max(lines.length, CONTEXT_MAX_ROWS);
98
+ let lines = context.split('\n');
99
+ if (maxRows < CONTEXT_MAX_ROWS && lines.length > maxRows)
100
+ lines = [lines.join(' ')];
101
+ let budget = maxRows < CONTEXT_MAX_ROWS ? maxRows : Math.max(lines.length, CONTEXT_MAX_ROWS);
143
102
  const out = [];
144
103
  lines.forEach((line, i) => {
145
104
  const reserve = lines.length - i - 1; // one row for each line still to come
146
- const rows = wrapCardText(line, cells, Math.max(1, budget - reserve));
105
+ const rows = wrapRows(line, cells, Math.max(1, budget - reserve));
147
106
  out.push(...rows);
148
107
  budget -= rows.length;
149
108
  });
150
109
  return out;
151
110
  }
111
+ /**
112
+ * Lay out one option at `inner` cells. The label wraps onto LABEL_MAX_ROWS
113
+ * under its own text; the (recommended) / (default) suffix stays whole on
114
+ * the last row (a new row when there is room for one, else the label is cut
115
+ * to make room; with no room at all the label wins and the suffix drops).
116
+ * The description wraps onto DESCRIPTION_MAX_ROWS dim rows under the label.
117
+ */
118
+ export function optionRows(o, idx, q, inner, layout) {
119
+ const prefixWidth = 2 + `${idx + 1}. `.length + (q.multiSelect ? stringWidth(`${getGlyphs().tickOff} `) : 0);
120
+ const avail = Math.max(1, inner - prefixWidth);
121
+ const wantRec = o.recommended === true && !/\brecommended\b/i.test(o.label);
122
+ const def = q.defaultValue !== undefined && o.value === q.defaultValue;
123
+ const suffix = (wantRec ? REC : '') + (def ? DEFAULT_SUFFIX : '');
124
+ let label = wrapRows(o.label, avail, layout.labelRows);
125
+ let rec = wantRec;
126
+ let showDef = def;
127
+ if (suffix) {
128
+ // The suffix words are wrapped WITH the label; when they land whole at
129
+ // the end, they come off the text again and are drawn dim on that row.
130
+ const joined = wrapRows(`${sanitizeControlChars(o.label)}${suffix}`, avail, layout.labelRows);
131
+ const last = joined[joined.length - 1];
132
+ const bare = suffix.trimStart();
133
+ if (last.endsWith(bare) && !last.endsWith(getGlyphs().more)) {
134
+ const text = last.slice(0, last.length - bare.length).trimEnd();
135
+ label = [...joined.slice(0, -1), text];
136
+ }
137
+ else if (avail - stringWidth(suffix) >= 1) {
138
+ // Too long for the rows: the label is cut so the suffix stays whole.
139
+ const lastLabel = label[label.length - 1];
140
+ label = [...label.slice(0, -1), truncateToWidth(`${lastLabel} `, avail - stringWidth(suffix))];
141
+ }
142
+ else {
143
+ rec = false;
144
+ showDef = false; // no room at all: the label wins
145
+ }
146
+ }
147
+ const description = layout.descriptionRows > 0 && o.description
148
+ ? wrapRows(o.description, Math.max(1, inner - DESCRIPTION_INDENT.length), layout.descriptionRows).map((r) => `${DESCRIPTION_INDENT}${r}`)
149
+ : [];
150
+ return { label, rec, def: showDef, description, prefixWidth };
151
+ }
152
+ /** Rows one option takes on the card. */
153
+ const optionHeight = (r) => r.label.length + r.description.length;
152
154
  export function toCardQuestions(request) {
153
155
  if ('questions' in request) {
154
156
  return request.questions.map((q) => {
@@ -179,51 +181,106 @@ export function toCardQuestions(request) {
179
181
  keys: request.keys,
180
182
  }];
181
183
  }
184
+ /**
185
+ * The footer. Task 4: the same words as ConfirmCard ("1-N picks · ↑↓ move ·
186
+ * Enter chooses · Esc cancels"), with the real N (rows a digit reaches, at
187
+ * most 9). Multi-select says digits and Space tick; single-key answers add
188
+ * "keys y/n" as ConfirmCard does. Text rows keep "Enter submits".
189
+ */
182
190
  export function askCardHint(kind, opts = {}) {
183
191
  const nav = opts.multiQuestion ? ['Tab next question'] : [];
192
+ const digits = `1-${Math.max(1, Math.min(9, opts.count ?? 9))}`;
193
+ const keys = opts.keys && opts.keys.length > 0 ? [`keys ${opts.keys.join('/')}`] : [];
184
194
  switch (kind) {
185
195
  case 'text': return ['Enter submits', ...nav, 'Esc cancels'].join(' · ');
186
196
  case 'other': return ['Enter submits', ...nav, 'Esc goes back to the options'].join(' · ');
187
197
  case 'multi':
188
- // Kept under 78 cells (an 80-column card) so it never truncates; the
189
- // "(choose one or more)" question suffix carries the multi-select cue.
190
- return ['Space or 1-9 ticks a row', 'Enter confirms', ...nav, 'Esc cancels'].join(' · ');
198
+ // The "(choose one or more)" question suffix carries the multi-select cue.
199
+ return [`${digits} or Space ticks`, '↑↓ move', 'Enter chooses', ...nav, 'Esc cancels', ...keys].join(' · ');
191
200
  default:
192
- return ['↑↓ move', '1-9 or Enter picks', ...nav, 'Esc cancels'].join(' · ');
201
+ return [`${digits} picks`, '↑↓ move', 'Enter chooses', ...nav, 'Esc cancels', ...keys].join(' · ');
193
202
  }
194
203
  }
195
- export const REVIEW_HINT = '↑↓ move · 1-2 or Enter chooses · ← back to edit · Esc cancels';
204
+ export const REVIEW_HINT = '1-2 picks · ↑↓ move · Enter chooses · ← back to edit · Esc cancels';
196
205
  const REVIEW_CHOICES = ['Submit answers', 'Cancel'];
197
206
  const MULTI_SUFFIX = ' (choose one or more)';
198
207
  const REC = ' (recommended)';
199
- /**
200
- * Upper bound on the rows this card renders — the card's share of the
201
- * live-area height budget (spec §2; liveBudget decides showDescriptions).
202
- * Worst case over every question screen, plus the Review screen.
203
- */
204
- export function estimateAskCardRows(request, showDescriptions, width = uiWidth()) {
205
- const qs = toCardQuestions(request);
208
+ const DEFAULT_SUFFIX = ' (default)';
209
+ const suffixText = (r) => (r.rec ? REC : '') + (r.def ? DEFAULT_SUFFIX : '');
210
+ /** Content width: the card width less the frame's side and paddingX on both sides (Task 4). */
211
+ const askInner = (width) => Math.max(1, width - 4);
212
+ /** The frame's top and bottom edges. */
213
+ const FRAME_ROWS = 2;
214
+ /** The card's rows at `layout` — worst case over every question screen, plus the Review screen. */
215
+ function askRowsFor(qs, inner, layout) {
206
216
  const chipRows = qs.length > 1 ? 2 : 0; // tab row + its blank line
207
- const inner = Math.max(1, width - 2);
208
217
  let worst = 0;
209
218
  for (const q of qs) {
210
- const ctx = contextRows(q.context, inner).length;
219
+ const ctx = contextRows(q.context, inner, layout.contextRows).length;
211
220
  const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
212
- const qRows = questionRows(q.question, suffix, inner).length;
221
+ const qRows = questionRows(q.question, suffix, inner, layout.questionRows).length;
213
222
  let body = 2; // margin + the text input row
214
223
  if (!q.freeText) {
215
- const shown = Math.min(q.options.length, VISIBLE_OPTION_ROWS);
216
- const described = showDescriptions ? Math.min(shown, q.options.filter((o) => o.description).length) : 0;
224
+ // Every wrapped label and description row counts; with more options
225
+ // than the window shows, the tallest window of VISIBLE_OPTION_ROWS.
226
+ const heights = q.options.map((o, i) => optionHeight(optionRows(o, i, q, inner, layout)));
227
+ const span = Math.min(heights.length, VISIBLE_OPTION_ROWS);
228
+ let tallest = 0;
229
+ for (let start = 0; start + span <= heights.length; start++) {
230
+ tallest = Math.max(tallest, heights.slice(start, start + span).reduce((x, y) => x + y, 0));
231
+ }
217
232
  const arrows = q.options.length > VISIBLE_OPTION_ROWS ? 2 : 0;
218
- body = 1 + shown + described + arrows + (q.otherRow ? 1 : 0);
233
+ body = 1 + tallest + arrows + (q.otherRow ? 1 : 0);
219
234
  }
220
- worst = Math.max(worst, chipRows + qRows + ctx + body + 2); // question + body + margin + hint
235
+ worst = Math.max(worst, chipRows + qRows + ctx + body + 1 + FRAME_ROWS); // question + body + hint + frame
221
236
  }
222
237
  if (qs.length > 1) {
223
- worst = Math.max(worst, chipRows + 1 + qs.length + 1 + REVIEW_CHOICES.length + 2);
238
+ // title + one row per answer + margin + choices + hint + frame
239
+ worst = Math.max(worst, chipRows + 1 + qs.length + 1 + REVIEW_CHOICES.length + 1 + FRAME_ROWS);
224
240
  }
225
241
  return worst;
226
242
  }
243
+ /**
244
+ * The one place the card's height budget is decided (fix round 1, review
245
+ * I1). Labels and descriptions wrap to their full rows; when the card would
246
+ * be taller than `maxRows` (App: the live area's room for a card), it folds
247
+ * in this order until it fits: labels to 1 row, descriptions to 1 row, no
248
+ * descriptions, then (Task 4) the context to 3 rows and the question to 2.
249
+ * `showDescriptions` false (liveBudget) starts with none. Frame rows count.
250
+ */
251
+ export function askCardLayout(request, showDescriptions, width = uiWidth(), maxRows) {
252
+ const layout = {
253
+ labelRows: LABEL_MAX_ROWS,
254
+ descriptionRows: showDescriptions ? DESCRIPTION_MAX_ROWS : 0,
255
+ contextRows: CONTEXT_MAX_ROWS,
256
+ questionRows: QUESTION_MAX_ROWS,
257
+ };
258
+ if (maxRows === undefined)
259
+ return layout;
260
+ const qs = toCardQuestions(request);
261
+ const inner = askInner(width);
262
+ const folds = [
263
+ (l) => { l.labelRows = 1; },
264
+ (l) => { l.descriptionRows = Math.min(l.descriptionRows, 1); },
265
+ (l) => { l.descriptionRows = 0; },
266
+ (l) => { l.contextRows = CONTEXT_FOLDED_ROWS; },
267
+ (l) => { l.questionRows = QUESTION_FOLDED_ROWS; },
268
+ ];
269
+ for (const fold of folds) {
270
+ if (askRowsFor(qs, inner, layout) <= maxRows)
271
+ break;
272
+ fold(layout);
273
+ }
274
+ return layout;
275
+ }
276
+ /**
277
+ * Upper bound on the rows this card renders — the card's share of the
278
+ * live-area height budget (spec §2; liveBudget decides showDescriptions).
279
+ * With `maxRows`, the height after askCardLayout's folds.
280
+ */
281
+ export function estimateAskCardRows(request, showDescriptions, width = uiWidth(), maxRows) {
282
+ return askRowsFor(toCardQuestions(request), askInner(width), askCardLayout(request, showDescriptions, width, maxRows));
283
+ }
227
284
  /**
228
285
  * Cut differently-styled segments of ONE row to `cells` in total: whole
229
286
  * segments while they fit, then the one that overflows ends with the `more`
@@ -250,7 +307,7 @@ function fitSegments(segs, cells) {
250
307
  }
251
308
  return out;
252
309
  }
253
- export function AskCard({ request, width, showDescriptions, onResolveV1, onResolveForm, onInterruptTurn }) {
310
+ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1, onResolveForm, onInterruptTurn }) {
254
311
  useReclaimRawStdin();
255
312
  const g = getGlyphs();
256
313
  const dim = roleProps('dim');
@@ -370,6 +427,13 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
370
427
  return; // v2: ignored (today)
371
428
  }
372
429
  // custom marks the free-text ESCAPE on an optioned question only.
430
+ // Task 4 (m3): on a multi-select question the ticked options are kept.
431
+ // The answer is their labels, then the typed words (all typed text).
432
+ if (onOther && q?.multiSelect) {
433
+ const ticked = q.options.filter((o) => ticks[focus].has(o.value)).map((o) => o.label);
434
+ answerAndAdvance(focus, [...ticked, trimmed].join(', '), true);
435
+ return;
436
+ }
373
437
  answerAndAdvance(focus, trimmed, onOther);
374
438
  };
375
439
  useInput((input, key) => {
@@ -382,8 +446,12 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
382
446
  if (key.ctrl && input === 'c') {
383
447
  const first = !done.current;
384
448
  cancel();
385
- if (first)
449
+ if (first) {
450
+ // Task 4 (m2): a caller asking a series (the spec-gap blockers) stops the rest.
451
+ if (isForm)
452
+ request.onInterrupt?.();
386
453
  onInterruptTurn?.();
454
+ }
387
455
  return;
388
456
  }
389
457
  if (key.escape) {
@@ -472,7 +540,10 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
472
540
  pickEnter();
473
541
  }, { isActive: true });
474
542
  const numbered = (i) => `${i + 1}. `;
475
- const inner = Math.max(1, width - 2); // paddingX={1} on both sides
543
+ const inner = askInner(width); // the frame's sides and paddingX={1}
544
+ const frameHeader = isForm ? request.frameHeader : undefined;
545
+ // The same fold as estimateAskCardRows, so the drawn card is the estimated card.
546
+ const layout = askCardLayout(request, showDescriptions, width, maxRows);
476
547
  const chipRow = n > 1 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "truncate-end", children: fitSegments([
477
548
  ...questions.map((qq, i) => ({
478
549
  text: `${i > 0 ? ' ' : ''}${answers[i] !== null ? g.answered : g.pending} ${fit(qq.header, inner)}`,
@@ -483,7 +554,7 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
483
554
  if (!q) {
484
555
  // Review step (forms with 2+ questions, all answered).
485
556
  const headerCol = Math.min(14, Math.max(...questions.map((qq) => stringWidth(fit(qq.header, inner)))) + 2);
486
- return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, flexShrink: 0, children: [chipRow, _jsx(Text, { bold: true, wrap: "truncate-end", children: fit('Review your answers', inner) }), questions.map((qq, i) => {
557
+ return (_jsxs(CardFrame, { tag: "QUESTION", header: frameHeader, width: width, children: [chipRow, _jsx(Text, { bold: true, wrap: "truncate-end", children: fit('Review your answers', inner) }), questions.map((qq, i) => {
487
558
  const a = answers[i];
488
559
  const head = fit(qq.header, headerCol - 2);
489
560
  const cell = ` ${head}${' '.repeat(Math.max(0, headerCol - stringWidth(head)))}`;
@@ -492,30 +563,35 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
492
563
  }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: REVIEW_CHOICES.map((label, i) => {
493
564
  const cur = i === reviewCursor;
494
565
  return (_jsx(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: fit(`${cur ? `${g.cursor} ` : ' '}${numbered(i)}${label}`, inner) }, `rc-${i}`));
495
- }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(REVIEW_HINT, inner) }) })] }));
566
+ }) }), _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(REVIEW_HINT, inner) })] }));
496
567
  }
497
568
  const winStart = Math.max(0, Math.min(cursor - 3, q.options.length - VISIBLE_OPTION_ROWS));
498
569
  const visible = q.options.slice(winStart, winStart + VISIBLE_OPTION_ROWS);
499
570
  const below = Math.max(0, q.options.length - (winStart + VISIBLE_OPTION_ROWS));
500
- const inputWidth = Math.max(8, width - 2 - 2 - `${rowCount}. `.length - 1);
501
- const contextLines = contextRows(q.context, inner);
571
+ const inputWidth = Math.max(8, inner - 2 - `${rowCount}. `.length - 1);
572
+ const contextLines = contextRows(q.context, inner, layout.contextRows);
502
573
  const hintKind = q.freeText ? 'text' : onOther ? 'other' : q.multiSelect ? 'multi' : 'pick';
503
574
  // The multi-select suffix is the card's only multi cue: it stays whole and
504
575
  // the question is cut instead (the same rule as "(recommended)" below).
505
576
  const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
506
- const questionLines = questionRows(q.question, suffix, inner);
507
- return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, flexShrink: 0, children: [chipRow, questionLines.map((line, i) => (_jsx(Text, { bold: true, wrap: "truncate-end", children: line }, `q-${i}`))), contextLines.map((line, i) => (_jsx(Text, { ...dim, wrap: "truncate-end", children: line }, `ctx-${i}`))), q.freeText ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { ...roleProps('sap'), children: `${g.cursor} ` }), _jsx(TextInput, { value: drafts[focus], onChange: setDraft, onSubmit: onTextSubmit, maxDisplayWidth: inputWidth })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [winStart > 0 && _jsx(Text, { ...dim, wrap: "truncate-end", children: ` ↑ ${winStart} more` }), visible.map((o, vi) => {
577
+ const questionLines = questionRows(q.question, suffix, inner, layout.questionRows);
578
+ const hint = askCardHint(hintKind, {
579
+ multiQuestion: n > 1,
580
+ count: rowCount,
581
+ keys: Object.keys(q.keys ?? {}).filter((k) => k.length === 1),
582
+ });
583
+ // Task 4 — the same frame as ConfirmCard: QUESTION tag (plus the optional
584
+ // header) on the top edge, the hint right under the options.
585
+ return (_jsxs(CardFrame, { tag: "QUESTION", header: frameHeader, width: width, children: [chipRow, questionLines.map((line, i) => (_jsx(Text, { bold: true, wrap: "truncate-end", children: line }, `q-${i}`))), contextLines.map((line, i) => (_jsx(Text, { ...dim, wrap: "truncate-end", children: line }, `ctx-${i}`))), q.freeText ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { ...roleProps('sap'), children: `${g.cursor} ` }), _jsx(TextInput, { value: drafts[focus], onChange: setDraft, onSubmit: onTextSubmit, maxDisplayWidth: inputWidth })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [winStart > 0 && _jsx(Text, { ...dim, wrap: "truncate-end", children: ` ↑ ${winStart} more` }), visible.map((o, vi) => {
508
586
  const idx = winStart + vi;
509
587
  const cur = !onOther && idx === cursor;
510
- const rec = o.recommended === true && !/\brecommended\b/i.test(o.label);
511
588
  const tick = q.multiSelect ? `${ticks[focus].has(o.value) ? g.tickOn : g.tickOff} ` : '';
512
589
  const prefix = `${cur ? `${g.cursor} ` : ' '}${numbered(idx)}${tick}`;
513
- const avail = inner - stringWidth(prefix);
514
- const label = sanitize(o.label);
515
- // (recommended) stays whole: the label is cut first; with no room
516
- // for both, the label wins and the dim suffix is dropped.
517
- const showRec = rec && avail - REC.length >= 1;
518
- const shownLabel = truncateToWidth(label, showRec ? avail - REC.length : avail);
519
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: [prefix, shownLabel, showRec ? _jsx(Text, { ...dim, children: REC }) : null, q.defaultValue !== undefined && o.value === q.defaultValue ? _jsx(Text, { ...dim, children: " (default)" }) : null] }), showDescriptions && o.description ? (_jsx(Text, { ...dim, wrap: "truncate-end", children: fit(` ${o.description}`, inner) })) : null] }, `opt-${idx}`));
520
- }), below > 0 && _jsx(Text, { ...dim, wrap: "truncate-end", children: ` ↓ ${below} more` }), q.otherRow ? (_jsxs(Box, { children: [_jsxs(Text, { wrap: "truncate-end", ...(onOther ? selectedProps() : {}), children: [onOther ? `${g.cursor} ` : ' ', numbered(q.options.length)] }), onOther ? (_jsx(TextInput, { value: drafts[focus], onChange: setDraft, onSubmit: onTextSubmit, placeholder: `Type something else${g.more}`, maxDisplayWidth: inputWidth })) : (_jsx(Text, { ...dim, wrap: "truncate-end", children: `Type something else${g.more}` }))] })) : null] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(askCardHint(hintKind, { multiQuestion: n > 1 }), inner) }) })] }));
590
+ const r = optionRows(o, idx, q, inner, layout);
591
+ const pad = ' '.repeat(r.prefixWidth);
592
+ const lastRow = r.label.length - 1;
593
+ return (_jsxs(Box, { flexDirection: "column", children: [r.label.map((row, j) => (_jsxs(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: [j === 0 ? prefix : pad, row, j === lastRow && (r.rec || r.def) ? (
594
+ // A suffix that wrapped onto a row of its own sits under the label text, no leading blank.
595
+ _jsx(Text, { ...dim, children: row === '' ? suffixText(r).trimStart() : suffixText(r) })) : null] }, `l-${j}`))), r.description.map((row, j) => (_jsx(Text, { ...dim, wrap: "truncate-end", children: row }, `d-${j}`)))] }, `opt-${idx}`));
596
+ }), below > 0 && _jsx(Text, { ...dim, wrap: "truncate-end", children: ` ↓ ${below} more` }), q.otherRow ? (_jsxs(Box, { children: [_jsxs(Text, { wrap: "truncate-end", ...(onOther ? selectedProps() : {}), children: [onOther ? `${g.cursor} ` : ' ', numbered(q.options.length)] }), onOther ? (_jsx(TextInput, { value: drafts[focus], onChange: setDraft, onSubmit: onTextSubmit, placeholder: `Type something else${g.more}`, maxDisplayWidth: inputWidth })) : (_jsx(Text, { ...dim, wrap: "truncate-end", children: `Type something else${g.more}` }))] })) : null] })), _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(hint, inner) })] }));
521
597
  }