@cspeach/cli 1.1.52 → 1.1.54

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.
Files changed (85) hide show
  1. package/dist/agent/conversation-prompt.js +1 -0
  2. package/dist/agent/loop.js +150 -17
  3. package/dist/agent/maybe-build-project-context.js +11 -2
  4. package/dist/agent/repair-partial.js +9 -0
  5. package/dist/agent/tool-dispatch.js +17 -2
  6. package/dist/approvals/op-labels.js +6 -3
  7. package/dist/approvals/render.js +3 -2
  8. package/dist/auth/auth-file.js +3 -0
  9. package/dist/auth/key-resolver.js +8 -3
  10. package/dist/auth/me.js +6 -2
  11. package/dist/cli.js +22 -0
  12. package/dist/commands/login.js +22 -4
  13. package/dist/commands/plan-chain.js +2 -2
  14. package/dist/commands/plan-reground.js +5 -0
  15. package/dist/commands/plan-resume.js +2 -2
  16. package/dist/commands/plan-startup.js +3 -3
  17. package/dist/commands/sap-link.js +14 -1
  18. package/dist/commands/team-build-help.js +19 -0
  19. package/dist/commands/team-build.js +97 -0
  20. package/dist/commands/team-plan.js +2 -2
  21. package/dist/commands/team-spec.js +107 -0
  22. package/dist/commands/team.js +74 -4
  23. package/dist/docx/doc-meta.js +1 -0
  24. package/dist/docx/markdown-to-docx.js +2 -0
  25. package/dist/journey/chain.js +8 -0
  26. package/dist/journey/clean-core.js +2 -2
  27. package/dist/journey/rebuild.js +8 -0
  28. package/dist/journey/run.js +9 -0
  29. package/dist/lane/app-chain.js +13 -6
  30. package/dist/licence/paths.js +5 -0
  31. package/dist/licence/seat-kind.js +110 -0
  32. package/dist/licence/seat-state.js +4 -0
  33. package/dist/licence/terminal-seat.js +43 -0
  34. package/dist/licence/turn-gate.js +4 -0
  35. package/dist/one-shot.js +33 -0
  36. package/dist/project-context/conventions.js +8 -1
  37. package/dist/project-context/index-files.js +97 -34
  38. package/dist/project-context/index.js +1 -1
  39. package/dist/project-context/render.js +38 -4
  40. package/dist/projects/answer-blockers.js +133 -18
  41. package/dist/projects/blocker-answers-note.js +89 -0
  42. package/dist/projects/change-options.js +70 -0
  43. package/dist/projects/document-save.js +86 -0
  44. package/dist/projects/extract-design.js +4 -0
  45. package/dist/projects/extract-estimate.js +4 -0
  46. package/dist/projects/extract-spec-gap.js +8 -0
  47. package/dist/projects/index.js +1 -1
  48. package/dist/projects/plan-objects.js +24 -0
  49. package/dist/projects/plan-run.js +12 -2
  50. package/dist/projects/save-command.js +83 -20
  51. package/dist/projects/saved-lines.js +19 -17
  52. package/dist/projects/spec-share.js +239 -0
  53. package/dist/register/picture.js +2 -2
  54. package/dist/register/store.js +19 -0
  55. package/dist/renderer/change-card.js +14 -0
  56. package/dist/renderer/html-entities.js +25 -0
  57. package/dist/renderer/machine-blocks.js +54 -7
  58. package/dist/renderer/tool-labels.js +2 -0
  59. package/dist/renderer/tool-widget.js +20 -5
  60. package/dist/repl/builtin-commands.js +4 -3
  61. package/dist/repl/chat-lane.js +10 -1
  62. package/dist/repl/file-picker.js +52 -5
  63. package/dist/repl/mode-cycle.js +12 -0
  64. package/dist/repl/open-questions-gate.js +159 -0
  65. package/dist/repl/ready-phrase.js +108 -0
  66. package/dist/repl/startup-account.js +64 -1
  67. package/dist/repl/team-build-screen.js +116 -0
  68. package/dist/repl/team-build-start.js +178 -0
  69. package/dist/repl.js +216 -11
  70. package/dist/sap-link/policy.js +4 -0
  71. package/dist/sap-link/registry.js +9 -3
  72. package/dist/sap-link/server.js +4 -1
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/user-prompt.js +2 -0
  75. package/dist/tools/_team-work-path.js +1 -1
  76. package/dist/tools/approval.js +12 -1
  77. package/dist/tools/cap/sap-link-tool.js +2 -0
  78. package/dist/tools/project/spec_answers_record.js +243 -0
  79. package/dist/tools/project/team_findings.js +2 -2
  80. package/dist/tools/seat-gate.js +41 -0
  81. package/dist/tools/tool-loading.js +2 -0
  82. package/dist/ui/approval-modal.js +2 -1
  83. package/dist/ui/widgets/ask-card.js +171 -29
  84. package/dist/ui/widgets/files-card.js +19 -1
  85. package/package.json +2 -2
@@ -22,7 +22,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
22
22
  * the text input is not focused) · Esc cancels; on the free-text row Esc
23
23
  * first returns to the options.
24
24
  */
25
- import { useMemo, useRef, useState } from 'react';
25
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
26
26
  import { Box, Text, useInput } from 'ink';
27
27
  import TextInput from '../text-input.js';
28
28
  import { useReclaimRawStdin } from './reclaim-raw-stdin.js';
@@ -34,14 +34,31 @@ import { sanitizeControlChars } from '../../renderer/sanitize.js';
34
34
  import { wrapRows } from '../../renderer/wrap-rows.js';
35
35
  import { answerDisplayText } from '../ask-answer-text.js';
36
36
  import { CardFrame } from './confirm-card.js';
37
+ import { emitTrace } from '../../renderer/trace.js';
37
38
  /** Option rows visible at once — the rest scroll. */
38
39
  export const VISIBLE_OPTION_ROWS = 5;
39
40
  /**
40
41
  * Single-key cards (the Yes/No agent confirm) drop keys received in the first
41
42
  * GUARD_MS after mount, so a key typed ahead while output streamed cannot
42
43
  * answer the card.
44
+ *
45
+ * 1.1.53 Task 20 (F-P) — every card, keys or not, also ignores Enter, digit
46
+ * picks and alias keys for GUARD_MS after each screen appears (the card, each
47
+ * next question of a form, the Review step), so the Enter of a "pick, then
48
+ * Enter" habit cannot answer the card that comes next. Typing into the
49
+ * typed-answer row is never dropped.
43
50
  */
44
51
  const GUARD_MS = 150;
52
+ /** Task 20 — the per-screen pick window; GUARD_MS outside tests. */
53
+ let pickGuardMs = GUARD_MS;
54
+ /**
55
+ * Test seam only: interaction tests that drive keys faster than a person can
56
+ * type set 0; undefined restores GUARD_MS. The window itself is pinned by
57
+ * ask-card-stray-enter.test.tsx. The y/n mount guard is not affected.
58
+ */
59
+ export function setAskPickGuardMsForTest(ms) {
60
+ pickGuardMs = ms ?? GUARD_MS;
61
+ }
45
62
  /**
46
63
  * Card text is model- or tool-supplied. Tabs and newlines are flattened by
47
64
  * truncateToWidth; every OTHER control character is neutralised by the
@@ -56,7 +73,12 @@ const sanitize = sanitizeControlChars;
56
73
  function fit(text, cells) {
57
74
  return truncateToWidth(sanitize(text), cells);
58
75
  }
59
- /** Round 4 item 8b — the question wraps up to this many rows, then is cut. */
76
+ /**
77
+ * Round 4 item 8b — the question's base rows. 1.1.53 Task 14: no longer the
78
+ * cap. With a row budget (App) the current question grows past it to as many
79
+ * rows as it needs while the card still fits (askCardLayout); without one it
80
+ * shows whole. Only a question that still does not fit is cut.
81
+ */
60
82
  export const QUESTION_MAX_ROWS = 3;
61
83
  /** Context wraps too; it gets at least one row per line and at most this many in all (or its line count, if larger). */
62
84
  export const CONTEXT_MAX_ROWS = 6;
@@ -70,6 +92,8 @@ const DESCRIPTION_INDENT = ' ';
70
92
  export const CONTEXT_FOLDED_ROWS = 3;
71
93
  /** …and then the question to this many. */
72
94
  export const QUESTION_FOLDED_ROWS = 2;
95
+ /** 1.1.53 Task 14 — the dim last row of a cut question; the whole question is printed in the scrollback above. */
96
+ export const FULL_QUESTION_NOTE = 'The full question is printed above.';
73
97
  /** The question rows (at most `maxRows`), with the multi-select suffix kept whole on the last row. */
74
98
  function questionRows(question, suffix, cells, maxRows = QUESTION_MAX_ROWS) {
75
99
  if (!suffix)
@@ -87,6 +111,58 @@ function questionRows(question, suffix, cells, maxRows = QUESTION_MAX_ROWS) {
87
111
  }
88
112
  return rows;
89
113
  }
114
+ /**
115
+ * 1.1.53 Task 14 — the question as plain wrapped text for the scrollback:
116
+ * each line of it wrapped at `cells`, nothing cut. Printed above the card
117
+ * when the card has to cut the question, so the person can read it whole.
118
+ */
119
+ export function fullQuestionText(question, cells) {
120
+ return question.split(/\r?\n/).map((line) => wrapRows(line, cells, Number.MAX_SAFE_INTEGER).join('\n')).join('\n');
121
+ }
122
+ /** The multi-select suffix, when the width has room for it (kept whole on the question's last row). */
123
+ function questionSuffix(q, cells) {
124
+ return q.multiSelect && cells - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
125
+ }
126
+ /** Rows the whole question needs (suffix included). */
127
+ function questionNeed(q, cells) {
128
+ // The layout asks this many times per render (folds, growth, estimate): memoised.
129
+ const key = `${cells}|${q.multiSelect ? 1 : 0}|${q.question}`;
130
+ let need = needCache.get(key);
131
+ if (need === undefined) {
132
+ need = questionRows(q.question, questionSuffix(q, cells), cells, Number.MAX_SAFE_INTEGER).length;
133
+ if (needCache.size >= 64)
134
+ needCache.clear();
135
+ needCache.set(key, need);
136
+ }
137
+ return need;
138
+ }
139
+ const needCache = new Map();
140
+ /** Fix round 2 (I-1) — a cut question gets the pointer row only from this many rows; below, the old cut rows. */
141
+ const POINTER_MIN_ROWS = 3;
142
+ /**
143
+ * 1.1.53 Task 14 — the question as drawn in at most `cap` rows. It fits: the
144
+ * same rows as before. It does not: with `cap` of POINTER_MIN_ROWS or more it
145
+ * is cut to `cap - 1` rows and the last row is the FULL_QUESTION_NOTE pointer;
146
+ * with fewer (the short-screen fold) it is cut to `cap` rows exactly as
147
+ * before, no pointer. Either way the block is min(need, cap) rows tall, so
148
+ * the height math is today's.
149
+ */
150
+ function questionBlock(q, cells, cap) {
151
+ // Memoised like questionNeed: the fold and growth loops ask for many caps.
152
+ const key = `${cells}|${q.multiSelect ? 1 : 0}|${cap}|${q.question}`;
153
+ const hit = blockCache.get(key);
154
+ if (hit)
155
+ return hit;
156
+ const suffix = questionSuffix(q, cells);
157
+ const block = questionNeed(q, cells) <= cap
158
+ ? { lines: questionRows(q.question, suffix, cells, cap), cut: false }
159
+ : { lines: questionRows(q.question, suffix, cells, cap >= POINTER_MIN_ROWS ? cap - 1 : cap), cut: true };
160
+ if (blockCache.size >= 256)
161
+ blockCache.clear();
162
+ blockCache.set(key, block);
163
+ return block;
164
+ }
165
+ const blockCache = new Map();
90
166
  /**
91
167
  * Context rows: every line keeps at least one row; wrapping fills the rest of
92
168
  * the budget. Folded (`maxRows` below CONTEXT_MAX_ROWS, Task 4) the context
@@ -108,6 +184,8 @@ function contextRows(context, cells, maxRows = CONTEXT_MAX_ROWS) {
108
184
  });
109
185
  return out;
110
186
  }
187
+ /** The rows question `i` may take at `layout`. */
188
+ const questionCap = (layout, i) => layout.questionRowsFor?.[i] ?? layout.questionRows;
111
189
  /**
112
190
  * Lay out one option at `inner` cells. The label wraps onto LABEL_MAX_ROWS
113
191
  * under its own text; the (recommended) / (default) suffix stays whole on
@@ -151,6 +229,17 @@ export function optionRows(o, idx, q, inner, layout) {
151
229
  }
152
230
  /** Rows one option takes on the card. */
153
231
  const optionHeight = (r) => r.label.length + r.description.length;
232
+ /**
233
+ * 1.1.53 R16 — the digit 1-9 a key chunk stands for, or 0. Remote desktops,
234
+ * VDI, ConPTY and key drivers can deliver a digit and Enter as one chunk
235
+ * ("2\r"): it counts as the digit alone, and the newline goes with it (never
236
+ * on to a following card). Any other multi-byte chunk ("22", an arrow plus a
237
+ * digit) is no digit.
238
+ */
239
+ function digitKey(input) {
240
+ const m = /^([1-9])[\r\n]*$/.exec(input);
241
+ return m ? Number(m[1]) : 0;
242
+ }
154
243
  export function toCardQuestions(request) {
155
244
  if ('questions' in request) {
156
245
  return request.questions.map((q) => {
@@ -212,29 +301,31 @@ const suffixText = (r) => (r.rec ? REC : '') + (r.def ? DEFAULT_SUFFIX : '');
212
301
  const askInner = (width) => Math.max(1, width - 4);
213
302
  /** The frame's top and bottom edges. */
214
303
  const FRAME_ROWS = 2;
304
+ /** The rows of one question's screen when its question may take `cap` rows. */
305
+ function screenRows(q, chipRows, inner, layout, cap) {
306
+ const ctx = contextRows(q.context, inner, layout.contextRows).length;
307
+ const block = questionBlock(q, inner, cap);
308
+ const qRows = block.lines.length + (block.cut && cap >= POINTER_MIN_ROWS ? 1 : 0); // + the pointer row
309
+ let body = 2; // margin + the text input row
310
+ if (!q.freeText) {
311
+ // Every wrapped label and description row counts; with more options
312
+ // than the window shows, the tallest window of VISIBLE_OPTION_ROWS.
313
+ const heights = q.options.map((o, i) => optionHeight(optionRows(o, i, q, inner, layout)));
314
+ const span = Math.min(heights.length, VISIBLE_OPTION_ROWS);
315
+ let tallest = 0;
316
+ for (let start = 0; start + span <= heights.length; start++) {
317
+ tallest = Math.max(tallest, heights.slice(start, start + span).reduce((x, y) => x + y, 0));
318
+ }
319
+ const arrows = q.options.length > VISIBLE_OPTION_ROWS ? 2 : 0;
320
+ body = 1 + tallest + arrows + (q.otherRow ? 1 : 0);
321
+ }
322
+ return chipRows + qRows + ctx + body + 1 + FRAME_ROWS; // question + body + hint + frame
323
+ }
215
324
  /** The card's rows at `layout` — worst case over every question screen, plus the Review screen. */
216
325
  function askRowsFor(qs, inner, layout) {
217
326
  const chipRows = qs.length > 1 ? 2 : 0; // tab row + its blank line
218
327
  let worst = 0;
219
- for (const q of qs) {
220
- const ctx = contextRows(q.context, inner, layout.contextRows).length;
221
- const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
222
- const qRows = questionRows(q.question, suffix, inner, layout.questionRows).length;
223
- let body = 2; // margin + the text input row
224
- if (!q.freeText) {
225
- // Every wrapped label and description row counts; with more options
226
- // than the window shows, the tallest window of VISIBLE_OPTION_ROWS.
227
- const heights = q.options.map((o, i) => optionHeight(optionRows(o, i, q, inner, layout)));
228
- const span = Math.min(heights.length, VISIBLE_OPTION_ROWS);
229
- let tallest = 0;
230
- for (let start = 0; start + span <= heights.length; start++) {
231
- tallest = Math.max(tallest, heights.slice(start, start + span).reduce((x, y) => x + y, 0));
232
- }
233
- const arrows = q.options.length > VISIBLE_OPTION_ROWS ? 2 : 0;
234
- body = 1 + tallest + arrows + (q.otherRow ? 1 : 0);
235
- }
236
- worst = Math.max(worst, chipRows + qRows + ctx + body + 1 + FRAME_ROWS); // question + body + hint + frame
237
- }
328
+ qs.forEach((q, i) => { worst = Math.max(worst, screenRows(q, chipRows, inner, layout, questionCap(layout, i))); });
238
329
  if (qs.length > 1) {
239
330
  // title + one row per answer + margin + choices + hint + frame
240
331
  worst = Math.max(worst, chipRows + 1 + qs.length + 1 + REVIEW_CHOICES.length + 1 + FRAME_ROWS);
@@ -256,10 +347,12 @@ export function askCardLayout(request, showDescriptions, width = uiWidth(), maxR
256
347
  contextRows: CONTEXT_MAX_ROWS,
257
348
  questionRows: QUESTION_MAX_ROWS,
258
349
  };
259
- if (maxRows === undefined)
260
- return layout;
261
350
  const qs = toCardQuestions(request);
262
351
  const inner = askInner(width);
352
+ // 1.1.53 Task 14 — no budget: every question shows whole.
353
+ if (maxRows === undefined) {
354
+ return { ...layout, questionRowsFor: qs.map((q) => Math.max(layout.questionRows, questionNeed(q, inner))) };
355
+ }
263
356
  const folds = [
264
357
  (l) => { l.labelRows = 1; },
265
358
  (l) => { l.descriptionRows = Math.min(l.descriptionRows, 1); },
@@ -272,6 +365,21 @@ export function askCardLayout(request, showDescriptions, width = uiWidth(), maxR
272
365
  break;
273
366
  fold(layout);
274
367
  }
368
+ // 1.1.53 Task 14 — then each question grows to the rows it needs while its
369
+ // screen still fits maxRows. What still does not fit stays cut (the AskCard
370
+ // prints it whole above). The question block is min(need, cap) rows tall
371
+ // (questionBlock), so the rest of the screen is measured once at the base
372
+ // and the cap follows directly (fix round 2, I-2 — no per-row re-wrap).
373
+ const chipRows = qs.length > 1 ? 2 : 0;
374
+ const base = layout.questionRows;
375
+ layout.questionRowsFor = qs.map((q) => {
376
+ const need = questionNeed(q, inner);
377
+ if (need <= base)
378
+ return base;
379
+ const rest = screenRows(q, chipRows, inner, layout, base) - base; // everything but the question
380
+ const room = maxRows - rest;
381
+ return need <= room ? need : Math.max(base, room);
382
+ });
275
383
  return layout;
276
384
  }
277
385
  /**
@@ -320,6 +428,9 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
320
428
  // while output streamed must not answer the card.
321
429
  const mountGuard = questions.some((qq) => qq.keys !== undefined);
322
430
  const mountedAt = useRef(Date.now());
431
+ // Task 20 — when the current screen appeared (reset on every focus change).
432
+ const screenAt = useRef(Date.now());
433
+ const pickGuarded = () => Date.now() - screenAt.current < pickGuardMs;
323
434
  const done = useRef(false);
324
435
  // Focus slots: 0..n-1 = questions; n = the Review step (2+ questions,
325
436
  // reachable only once every question has an answer).
@@ -329,6 +440,16 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
329
440
  const [drafts, setDrafts] = useState(() => questions.map(() => ''));
330
441
  const [answers, setAnswers] = useState(() => questions.map(() => null));
331
442
  const [reviewCursor, setReviewCursor] = useState(0);
443
+ const firstScreen = useRef(true);
444
+ useLayoutEffect(() => {
445
+ if (firstScreen.current) {
446
+ firstScreen.current = false;
447
+ return;
448
+ }
449
+ screenAt.current = Date.now();
450
+ }, [focus]);
451
+ // 1.1.53 Task 14 — questions already printed whole to the scrollback.
452
+ const printedFull = useRef(new Set());
332
453
  const isReview = n > 1 && focus === n;
333
454
  const q = isReview ? undefined : questions[focus];
334
455
  const cursor = q ? cursors[focus] : 0;
@@ -421,6 +542,8 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
421
542
  answerAndAdvance(focus, opt.value);
422
543
  };
423
544
  const onTextSubmit = (v) => {
545
+ if (pickGuarded())
546
+ return; // Task 20 — a stray Enter does not submit the row
424
547
  const trimmed = v.trim();
425
548
  if (trimmed.length === 0) {
426
549
  if (!isForm)
@@ -483,11 +606,15 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
483
606
  setReviewCursor(1);
484
607
  return;
485
608
  }
486
- if (input === '1' || (key.return && reviewCursor === 0)) {
609
+ // R16 — "1\r" / "2\n" in one chunk act like the bare digit.
610
+ const reviewDigit = digitKey(input);
611
+ if (pickGuarded() && (reviewDigit > 0 || key.return))
612
+ return; // Task 20
613
+ if (reviewDigit === 1 || (key.return && reviewCursor === 0)) {
487
614
  submit(answers);
488
615
  return;
489
616
  }
490
- if (input === '2' || (key.return && reviewCursor === 1)) {
617
+ if (reviewDigit === 2 || (key.return && reviewCursor === 1)) {
491
618
  cancel();
492
619
  return;
493
620
  }
@@ -510,11 +637,14 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
510
637
  return;
511
638
  // Ctrl+Y / Meta+Y are not "y": only a bare key answers.
512
639
  const alias = key.ctrl || key.meta ? undefined : q.keys?.[input.toLowerCase()];
640
+ // Task 20 — Enter, digit picks and alias keys wait out the screen's window.
641
+ if (pickGuarded() && (alias !== undefined || digitKey(input) > 0 || key.return))
642
+ return;
513
643
  if (alias !== undefined && q.options.some((o) => o.value === alias)) {
514
644
  answerAndAdvance(focus, alias);
515
645
  return;
516
646
  }
517
- const digit = /^[1-9]$/.test(input) ? Number(input) - 1 : -1;
647
+ const digit = digitKey(input) - 1;
518
648
  if (digit >= 0) {
519
649
  if (digit >= rowCount)
520
650
  return;
@@ -545,6 +675,19 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
545
675
  const frameHeader = isForm ? request.frameHeader : undefined;
546
676
  // The same fold as estimateAskCardRows, so the drawn card is the estimated card.
547
677
  const layout = askCardLayout(request, showDescriptions, width, maxRows);
678
+ // 1.1.53 Task 14 — a question the card has to cut is printed whole, once,
679
+ // to the scrollback just above the card (where answered questions land),
680
+ // when it becomes the current question. A question that fits prints
681
+ // nothing. Classic / headless have no trace emitter: emitTrace is a no-op.
682
+ const block = q ? questionBlock(q, inner, questionCap(layout, focus)) : undefined;
683
+ const questionCut = block?.cut === true;
684
+ const showPointer = questionCut && questionCap(layout, focus) >= POINTER_MIN_ROWS;
685
+ useEffect(() => {
686
+ if (!q || !questionCut || printedFull.current.has(focus))
687
+ return;
688
+ printedFull.current.add(focus);
689
+ emitTrace(fullQuestionText(q.question, inner));
690
+ }, [q, questionCut, focus, inner]);
548
691
  const chipRow = n > 1 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "truncate-end", children: fitSegments([
549
692
  ...questions.map((qq, i) => ({
550
693
  text: `${i > 0 ? ' ' : ''}${answers[i] !== null ? g.answered : g.pending} ${fit(qq.header, inner)}`,
@@ -574,8 +717,7 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
574
717
  const hintKind = q.freeText ? 'text' : onOther ? 'other' : q.multiSelect ? 'multi' : 'pick';
575
718
  // The multi-select suffix is the card's only multi cue: it stays whole and
576
719
  // the question is cut instead (the same rule as "(recommended)" below).
577
- const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
578
- const questionLines = questionRows(q.question, suffix, inner, layout.questionRows);
720
+ const questionLines = block.lines;
579
721
  const hint = askCardHint(hintKind, {
580
722
  multiQuestion: n > 1,
581
723
  count: rowCount,
@@ -583,7 +725,7 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
583
725
  });
584
726
  // Task 4 — the same frame as ConfirmCard: QUESTION tag (plus the optional
585
727
  // header) on the top edge, the hint right under the options.
586
- 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) => {
728
+ 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}`))), showPointer ? _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(FULL_QUESTION_NOTE, inner) }) : null, 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) => {
587
729
  const idx = winStart + vi;
588
730
  const cur = !onOther && idx === cursor;
589
731
  const tick = q.multiSelect ? `${ticks[focus].has(o.value) ? g.tickOn : g.tickOff} ` : '';
@@ -24,6 +24,21 @@ export function filterFileItems(items, query) {
24
24
  function fit(text, cells) {
25
25
  return truncateToWidth(sanitizeControlChars(text), Math.max(1, cells));
26
26
  }
27
+ /** A folder path keeps its END when cut (the project and .cspeach part): `…` + tail. */
28
+ function fitTail(text, cells) {
29
+ const clean = sanitizeControlChars(text);
30
+ const room = Math.max(1, cells);
31
+ if (stringWidth(clean) <= room)
32
+ return clean;
33
+ const chars = [...clean];
34
+ let tail = '';
35
+ for (let i = chars.length - 1; i >= 0; i--) {
36
+ if (stringWidth(chars[i] + tail) > room - 1)
37
+ break;
38
+ tail = chars[i] + tail;
39
+ }
40
+ return `…${tail}`;
41
+ }
27
42
  export function FilesCard({ request, width, onResolve }) {
28
43
  // Same raw-stdin reclaim as every card (an earlier inquirer prompt can leave
29
44
  // Ink's raw-mode bookkeeping stale).
@@ -83,9 +98,12 @@ export function FilesCard({ request, width, onResolve }) {
83
98
  const inner = Math.max(1, width - 4);
84
99
  const dim = roleProps('dim');
85
100
  const searchLabel = 'Search ';
101
+ const folderLabel = 'Folder ';
86
102
  // A long query is cut by cells (with …) so the row never wraps.
87
103
  const queryShown = fit(query, inner - stringWidth(searchLabel) - 1);
88
- return (_jsxs(CardFrame, { tag: "QUESTION", footnote: "/files", width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: fit(request.title, inner) }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...dim, children: searchLabel }), _jsx(Text, { children: queryShown }), _jsx(Text, { ...roleProps('sap'), children: "_" })] }), visible.length === 0
104
+ return (_jsxs(CardFrame, { tag: "QUESTION", footnote: "/files", width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: fit(request.title, inner) }), request.folder
105
+ ? _jsx(Text, { wrap: "truncate-end", ...dim, children: folderLabel + fitTail(request.folder, inner - stringWidth(folderLabel)) })
106
+ : null, _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...dim, children: searchLabel }), _jsx(Text, { children: queryShown }), _jsx(Text, { ...roleProps('sap'), children: "_" })] }), visible.length === 0
89
107
  ? _jsx(Text, { ...dim, children: "No file matches" })
90
108
  : visible.map((it, i) => {
91
109
  const on = start + i === cursor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.52",
3
+ "version": "1.1.54",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -54,7 +54,7 @@
54
54
  "@anthropic-ai/sdk": "^0.90.0",
55
55
  "@azure/msal-node": "6.0.1",
56
56
  "@cspeach/register-core": "0.4.2",
57
- "@cspeach/sap-client": "0.1.30",
57
+ "@cspeach/sap-client": "0.1.31",
58
58
  "@iarna/toml": "^2.2.5",
59
59
  "@inkjs/ui": "^2.0.0",
60
60
  "@inquirer/prompts": "^7.0.0",