@cspeach/cli 1.1.18 → 1.1.20

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 (252) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/anthropic-provider.js +30 -10
  3. package/dist/agent/cache-keepalive.js +162 -0
  4. package/dist/agent/cold-prune.js +116 -0
  5. package/dist/agent/loop.js +804 -159
  6. package/dist/agent/provider-shape.js +263 -0
  7. package/dist/agent/providers/ai-hub-provider.js +17 -2
  8. package/dist/agent/providers/byok-provider.js +33 -3
  9. package/dist/agent/providers/local-provider.js +12 -2
  10. package/dist/agent/repair-partial.js +125 -7
  11. package/dist/agent/summarise-via-provider.js +6 -1
  12. package/dist/agent/system-prompt.js +38 -0
  13. package/dist/agent/tool-dispatch.js +66 -21
  14. package/dist/agent/tool-loading-pin.js +100 -0
  15. package/dist/agent/turn-error-ux.js +77 -26
  16. package/dist/agent/turn-stream.js +25 -3
  17. package/dist/approvals/adt-type.js +27 -0
  18. package/dist/approvals/advisory-prompt.js +48 -0
  19. package/dist/approvals/advisory-render.js +25 -11
  20. package/dist/approvals/approval-prompt.js +74 -24
  21. package/dist/approvals/jwt.js +2 -1
  22. package/dist/approvals/render.js +64 -36
  23. package/dist/approvals/risk-floor.js +53 -2
  24. package/dist/auth/api-key.js +30 -16
  25. package/dist/auth/keychain.js +0 -0
  26. package/dist/auth/me.js +25 -7
  27. package/dist/cli.js +36 -6
  28. package/dist/commands/auto-compact.js +33 -16
  29. package/dist/commands/compact.js +49 -11
  30. package/dist/commands/config-set.js +16 -3
  31. package/dist/commands/config-show.js +18 -5
  32. package/dist/commands/cost.js +54 -35
  33. package/dist/commands/help.js +88 -50
  34. package/dist/commands/login.js +28 -19
  35. package/dist/commands/logout.js +7 -3
  36. package/dist/commands/plan-audit.js +1 -0
  37. package/dist/commands/plan-chain.js +71 -77
  38. package/dist/commands/plan-continue.js +1 -0
  39. package/dist/commands/plan-resume.js +20 -12
  40. package/dist/commands/whoami.js +23 -9
  41. package/dist/config/loader.js +70 -5
  42. package/dist/cost/cost-log.js +62 -2
  43. package/dist/cost/pricing.js +11 -6
  44. package/dist/doctor/checks/forge-rules.js +13 -3
  45. package/dist/doctor/checks/keychain.js +6 -4
  46. package/dist/doctor/run.js +101 -20
  47. package/dist/index.js +5 -2
  48. package/dist/lib/graceful-exit.js +56 -0
  49. package/dist/lib/piped-prompt.js +65 -0
  50. package/dist/lib/spill-labels.js +13 -0
  51. package/dist/lock-contention.js +2 -2
  52. package/dist/models/resolve.js +93 -2
  53. package/dist/models/server-config.js +158 -3
  54. package/dist/one-shot.js +150 -45
  55. package/dist/projects/answer-blockers.js +6 -1
  56. package/dist/projects/handover-md.js +15 -14
  57. package/dist/projects/image-attachments.js +15 -2
  58. package/dist/projects/plan-run.js +97 -54
  59. package/dist/projects/promote-command.js +11 -2
  60. package/dist/projects/save-command.js +3 -2
  61. package/dist/projects/status.js +2 -1
  62. package/dist/projects/yes-no.js +12 -0
  63. package/dist/renderer/abap-inline.js +18 -14
  64. package/dist/renderer/answer-trim.js +69 -0
  65. package/dist/renderer/banners.js +8 -8
  66. package/dist/renderer/brand-settled.js +19 -0
  67. package/dist/renderer/change-card.js +156 -0
  68. package/dist/renderer/checklist-format.js +130 -0
  69. package/dist/renderer/color-mode.js +45 -0
  70. package/dist/renderer/error-detail.js +104 -0
  71. package/dist/renderer/fence-state.js +46 -0
  72. package/dist/renderer/footer-line.js +100 -0
  73. package/dist/renderer/glyphs.js +25 -0
  74. package/dist/renderer/goodbye.js +38 -0
  75. package/dist/renderer/legacy-palette.js +41 -0
  76. package/dist/renderer/look.js +30 -0
  77. package/dist/renderer/markdown.js +299 -92
  78. package/dist/renderer/notice-log.js +72 -0
  79. package/dist/renderer/notice-shape.js +48 -0
  80. package/dist/renderer/notices.js +40 -8
  81. package/dist/renderer/panel-rows.js +16 -0
  82. package/dist/renderer/pipeline.js +66 -0
  83. package/dist/renderer/progress-chatter.js +23 -17
  84. package/dist/renderer/rendering-mode.js +34 -0
  85. package/dist/renderer/routing-line.js +14 -0
  86. package/dist/renderer/sanitize.js +12 -0
  87. package/dist/renderer/severity.js +2 -7
  88. package/dist/renderer/startup-lines.js +154 -0
  89. package/dist/renderer/status-footer.js +52 -34
  90. package/dist/renderer/steering-echo.js +41 -0
  91. package/dist/renderer/syntax.js +31 -12
  92. package/dist/renderer/tables.js +5 -1
  93. package/dist/renderer/theme.js +44 -0
  94. package/dist/renderer/thinking-heartbeat.js +33 -2
  95. package/dist/renderer/todo-block.js +10 -49
  96. package/dist/renderer/tool-labels.js +269 -0
  97. package/dist/renderer/tool-widget.js +245 -51
  98. package/dist/renderer/trace.js +42 -0
  99. package/dist/renderer/transcript-flow.js +132 -0
  100. package/dist/renderer/tty.js +34 -1
  101. package/dist/renderer/ui-width.js +55 -0
  102. package/dist/renderer/verify-chain.js +4 -2
  103. package/dist/renderer/widget-fallback.js +58 -65
  104. package/dist/repl/bracketed-paste.js +7 -1
  105. package/dist/repl/builtin-commands.js +19 -8
  106. package/dist/repl/credential-handover-gate.js +28 -0
  107. package/dist/repl/current-transport.js +13 -0
  108. package/dist/repl/early-line-buffer.js +5 -2
  109. package/dist/repl/file-picker.js +54 -12
  110. package/dist/repl/ink-stdin-guard.js +66 -0
  111. package/dist/repl/inquirer-guard.js +59 -16
  112. package/dist/repl/inquirer-theme.js +27 -33
  113. package/dist/repl/paste-marker.js +19 -0
  114. package/dist/repl/plan-turn-end.js +20 -0
  115. package/dist/repl/post-turn-status.js +43 -11
  116. package/dist/repl/reroute-turn.js +21 -0
  117. package/dist/repl/reset-tty-stdin.js +44 -0
  118. package/dist/repl/restore-guard.js +22 -0
  119. package/dist/repl/resume-standalone.js +63 -0
  120. package/dist/repl/rule8-detector.js +11 -3
  121. package/dist/repl/safety-confirm.js +161 -93
  122. package/dist/repl/session-spend-line.js +8 -4
  123. package/dist/repl/themed-prompts.js +19 -0
  124. package/dist/repl/ui-look-command.js +75 -0
  125. package/dist/repl/update-method-preview-hook.js +48 -5
  126. package/dist/repl.js +863 -339
  127. package/dist/rewind/cli.js +3 -2
  128. package/dist/rewind/format.js +14 -9
  129. package/dist/rewind/restore.js +11 -0
  130. package/dist/router/classifier.js +47 -4
  131. package/dist/router/intent-extractor.js +24 -8
  132. package/dist/router/piped-routing.js +23 -0
  133. package/dist/router/resume-routing.js +16 -0
  134. package/dist/router/routing-failure.js +57 -0
  135. package/dist/sap/first-run-choice.js +1 -1
  136. package/dist/sap/onboarding.js +3 -2
  137. package/dist/sap/standalone-onboarding.js +1 -1
  138. package/dist/sap/system-info.js +4 -2
  139. package/dist/sap/unreachable.js +28 -0
  140. package/dist/sap-errors/clean-error-text.js +182 -0
  141. package/dist/session/interrupt-reason.js +39 -0
  142. package/dist/session/recap.js +36 -24
  143. package/dist/session/repin-model.js +18 -0
  144. package/dist/session/resume.js +104 -36
  145. package/dist/session/store.js +84 -28
  146. package/dist/session/user-prompt.js +21 -0
  147. package/dist/skill-catalog.js +7 -0
  148. package/dist/skills/bundled-skills.js +76 -69
  149. package/dist/skills/preamble.js +75 -0
  150. package/dist/skills/source-manifest.js +11 -1
  151. package/dist/standards/standards-init.js +1 -1
  152. package/dist/test-helpers/answer-any.js +46 -0
  153. package/dist/tools/approval.js +27 -11
  154. package/dist/tools/ask-question.js +47 -5
  155. package/dist/tools/filesystem/file-read.js +11 -1
  156. package/dist/tools/filesystem/file-write.js +19 -2
  157. package/dist/tools/fiori/fe-extend.js +16 -1
  158. package/dist/tools/fiori/fe-scaffold.js +105 -3
  159. package/dist/tools/fiori/html-escapes.js +28 -0
  160. package/dist/tools/fiori/metadata/audit.js +605 -0
  161. package/dist/tools/fiori/metadata/references.js +104 -0
  162. package/dist/tools/fiori/metadata/types.js +8 -0
  163. package/dist/tools/fiori/preview/app-guard.js +215 -0
  164. package/dist/tools/fiori/preview/env.js +193 -0
  165. package/dist/tools/fiori/preview/readiness.js +127 -0
  166. package/dist/tools/fiori/preview/registry.js +412 -0
  167. package/dist/tools/fiori/preview/start.js +469 -0
  168. package/dist/tools/fiori/samples/loader.js +20 -5
  169. package/dist/tools/fiori/scaffold.js +18 -6
  170. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  171. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  172. package/dist/tools/fiori/smoke/assertions.js +161 -24
  173. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  174. package/dist/tools/fiori/smoke/batch.js +273 -0
  175. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  176. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  177. package/dist/tools/fiori/smoke/driver.js +171 -4
  178. package/dist/tools/fiori/smoke/evidence.js +35 -0
  179. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  180. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  181. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  182. package/dist/tools/fiori/tools.js +651 -7
  183. package/dist/tools/fiori/ui5-version.js +16 -0
  184. package/dist/tools/index.js +8 -0
  185. package/dist/tools/local-build.js +6 -0
  186. package/dist/tools/result-spill.js +238 -0
  187. package/dist/tools/sap-read.js +74 -15
  188. package/dist/tools/sap-write.js +122 -10
  189. package/dist/tools/shell/shell_exec.js +39 -12
  190. package/dist/tools/subagent/adt-serial.js +33 -0
  191. package/dist/tools/subagent/agent_run.js +2 -0
  192. package/dist/tools/subagent/read_agent.js +178 -0
  193. package/dist/tools/subagent/reader-prompt.js +48 -0
  194. package/dist/tools/syntax-state.js +67 -0
  195. package/dist/tools/todo.js +26 -19
  196. package/dist/tools/tool-loading.js +255 -0
  197. package/dist/tools/tool-output-read.js +117 -0
  198. package/dist/tools/transport-fit.js +462 -0
  199. package/dist/tools/transport-resolution.js +3 -1
  200. package/dist/tools/transport.js +39 -8
  201. package/dist/tools/verify.js +184 -8
  202. package/dist/ui/app.js +284 -146
  203. package/dist/ui/approval-modal.js +41 -15
  204. package/dist/ui/ask-answer-text.js +17 -0
  205. package/dist/ui/body.js +25 -6
  206. package/dist/ui/card-slot.js +98 -0
  207. package/dist/ui/checklist.js +9 -0
  208. package/dist/ui/coaching-picker-classic.js +8 -5
  209. package/dist/ui/command-palette.js +6 -4
  210. package/dist/ui/confirm-request.js +18 -0
  211. package/dist/ui/context-grid.js +2 -1
  212. package/dist/ui/file-palette.js +6 -4
  213. package/dist/ui/files-card-emitter.js +19 -0
  214. package/dist/ui/footer-line.js +16 -0
  215. package/dist/ui/footer.js +105 -104
  216. package/dist/ui/input-wrap.js +122 -0
  217. package/dist/ui/interrupt.js +103 -0
  218. package/dist/ui/key-burst.js +117 -0
  219. package/dist/ui/line-resolution.js +4 -3
  220. package/dist/ui/live-area.js +71 -0
  221. package/dist/ui/login-banner.js +54 -43
  222. package/dist/ui/rewind-panel.js +76 -24
  223. package/dist/ui/role-props.js +47 -0
  224. package/dist/ui/sap-state-store.js +2 -1
  225. package/dist/ui/session-timeline.js +49 -3
  226. package/dist/ui/skill-picker.js +5 -47
  227. package/dist/ui/text-input.js +128 -24
  228. package/dist/ui/todo-emitter.js +19 -0
  229. package/dist/ui/turn-status-emitter.js +65 -2
  230. package/dist/ui/turn-status.js +16 -19
  231. package/dist/ui/use-card-request.js +44 -0
  232. package/dist/ui/widgets/ask-card.js +521 -0
  233. package/dist/ui/widgets/bar-chart.js +11 -5
  234. package/dist/ui/widgets/coaching-picker.js +50 -55
  235. package/dist/ui/widgets/confirm-card.js +193 -0
  236. package/dist/ui/widgets/dep-graph.js +3 -2
  237. package/dist/ui/widgets/diff-viewer.js +6 -5
  238. package/dist/ui/widgets/files-card.js +97 -0
  239. package/dist/ui/widgets/question-card.js +2 -1
  240. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  241. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  242. package/dist/ui/widgets/stack-frames.js +2 -1
  243. package/dist/upgrade-check.js +39 -8
  244. package/package.json +12 -2
  245. package/test-harness/fe-draft/app.ts +188 -0
  246. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  247. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  248. package/test-harness/fe-draft/routes.ts +224 -0
  249. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  250. package/test-harness/fe-draft/server.ts +225 -0
  251. package/test-harness/fe-draft/variants.ts +196 -0
  252. package/vitest.smoke-e2e.config.ts +20 -0
@@ -1,78 +1,71 @@
1
+ // src/renderer/widget-fallback.ts
2
+ //
3
+ // Piece 2 §9/§5: the off-Ink question widget is a plain card in the confirm-
4
+ // card frame — QUESTION chip on the top edge, the question in bold, dim
5
+ // context, numbered options, and how to answer on the bottom edge. Every
6
+ // line is exactly `width` cells (uiWidth() by default); text is sanitised
7
+ // and wrapped BEFORE colouring so no cut can land inside an escape code and
8
+ // model text can never drive the terminal.
9
+ //
10
+ // The frame geometry mirrors CardFrame/edgeSegments (ui/widgets/confirm-card.tsx)
11
+ // as plain strings: that module is an Ink component, and this path runs off
12
+ // Ink (piped / CI), so it must not pull Ink and React in.
1
13
  import chalk from 'chalk';
2
- // Inner content width — the box becomes content+4 total width.
3
- const CONTENT_WIDTH = 55;
4
- const TOTAL_WIDTH = CONTENT_WIDTH + 4; // left "┃ " + content + " ┃"
5
- function padLine(s) {
6
- // Pad to CONTENT_WIDTH visible chars, preserve ANSI codes if any
7
- const ANSI = /\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl]/g;
8
- const visible = s.replace(ANSI, '');
9
- const padding = Math.max(0, CONTENT_WIDTH - visible.length);
10
- return s + ' '.repeat(padding);
11
- }
14
+ import stringWidth from 'string-width';
15
+ import { paint, chip } from './theme.js';
16
+ import { getBoxChars } from '../ui/ascii-chars.js';
17
+ import { uiWidth, truncateToWidth } from './ui-width.js';
18
+ import { ANSI_RE } from './tool-labels.js';
19
+ import { sanitizeControlChars } from './sanitize.js';
20
+ const vis = (s) => stringWidth(s.replace(ANSI_RE, ''));
21
+ /** Word-wrap plain text to `width` cells; a single over-long word is truncated. */
12
22
  function wrap(text, width) {
13
- if (text.length <= width)
14
- return [text];
15
- const words = text.split(/\s+/);
23
+ const words = sanitizeControlChars(text).replace(/\t/g, ' ').split(/\s+/).filter((w) => w.length > 0);
16
24
  const lines = [];
17
- let current = '';
18
- for (const word of words) {
19
- if (current.length === 0) {
20
- current = word;
21
- }
22
- else if (current.length + 1 + word.length <= width) {
23
- current += ' ' + word;
24
- }
25
+ let cur = '';
26
+ for (const raw of words) {
27
+ const word = stringWidth(raw) > width ? truncateToWidth(raw, width) : raw;
28
+ if (cur === '')
29
+ cur = word;
30
+ else if (stringWidth(cur) + 1 + stringWidth(word) <= width)
31
+ cur += ' ' + word;
25
32
  else {
26
- lines.push(current);
27
- current = word;
33
+ lines.push(cur);
34
+ cur = word;
28
35
  }
29
36
  }
30
- if (current.length > 0)
31
- lines.push(current);
32
- return lines;
37
+ if (cur !== '')
38
+ lines.push(cur);
39
+ return lines.length > 0 ? lines : [''];
33
40
  }
34
- /**
35
- * Render a question widget as a heavy-bordered chalk card for classic-mode
36
- * REPL output. Called by the pipeline's widget-fallback path when
37
- * shouldUseInk() is false OR when we're rendering the default text
38
- * fallback for the widget system.
39
- *
40
- * Uses heavy box-drawing chars (┏ ┓ ┗ ┛ ━ ┃) so the question card
41
- * visually distinguishes from the light-bordered tool-widget boxes.
42
- */
43
- export function renderQuestionCard(widget) {
44
- const border = chalk.cyan;
45
- const dim = chalk.dim;
46
- const top = border('┏━ ' + chalk.cyan.bold(`Question: ${widget.id}`) + ' ' + '━'.repeat(Math.max(0, CONTENT_WIDTH - widget.id.length - 12)) + '━┓');
47
- const bottom = border('┗' + '━'.repeat(TOTAL_WIDTH - 2) + '┛');
41
+ export function renderQuestionCard(widget, width = uiWidth(), box = getBoxChars()) {
42
+ const inner = Math.max(10, width - 4); // "│ " + content + " │"
43
+ const row = (plain, style = (s) => s) => paint('frame', box.v) + ' ' + style(plain) + ' '.repeat(Math.max(0, inner - stringWidth(plain))) + ' ' + paint('frame', box.v);
44
+ // Top edge: ╭─<chip> <id> ───╮ (2 + chip + 1 + id + 1 + fill + 1 = width)
45
+ const tag = chip('QUESTION');
46
+ const idText = truncateToWidth(sanitizeControlChars(widget.id), Math.max(1, width - vis(tag) - 6));
47
+ const topFill = Math.max(0, width - (2 + vis(tag) + 1 + stringWidth(idText) + 1) - 1);
48
+ const top = paint('frame', box.tl + box.h) + tag + ' ' + chalk.bold(idText) + ' ' + paint('frame', box.h.repeat(topFill) + box.tr);
49
+ // Bottom edge: ╰───── <note> ─╯ (1 + fill + 1 + note + 1 + 1 + 1 = width)
50
+ const noteText = widget.kind === 'choice' ? 'reply with a number'
51
+ : widget.kind === 'multi' ? 'reply with numbers, comma-separated'
52
+ : 'reply in your next message';
53
+ const note = truncateToWidth(noteText, Math.max(1, width - 8));
54
+ const bottomFill = Math.max(0, width - stringWidth(note) - 5);
55
+ const bottom = paint('frame', box.bl + box.h.repeat(bottomFill)) + ' ' + paint('dim', note) + ' ' + paint('frame', box.h + box.br);
48
56
  const lines = [top];
49
- lines.push(border('┃ ') + padLine('') + border(' ┃'));
50
- // Question text (wrapped)
51
- for (const line of wrap(widget.question, CONTENT_WIDTH)) {
52
- lines.push(border('┃ ') + padLine(chalk.bold(line)) + border(' ┃'));
53
- }
54
- // Optional context
55
- if (widget.context) {
56
- lines.push(border('┃ ') + padLine('') + border(' ┃'));
57
- for (const line of wrap(`Context: ${widget.context}`, CONTENT_WIDTH)) {
58
- lines.push(border('┃ ') + padLine(dim(line)) + border(' ┃'));
59
- }
60
- }
61
- // Choice list for choice/multi kinds
57
+ for (const l of wrap(widget.question, inner))
58
+ lines.push(row(l, (s) => chalk.bold(s)));
59
+ if (widget.context)
60
+ for (const l of wrap(widget.context, inner))
61
+ lines.push(row(l, (s) => paint('dim', s)));
62
62
  if (widget.kind === 'choice' || widget.kind === 'multi') {
63
- lines.push(border('┃ ') + padLine('') + border(' ┃'));
64
- const header = widget.kind === 'choice' ? 'Pick one:' : 'Pick any (comma-separate):';
65
- lines.push(border('┃ ') + padLine(chalk.bold(header)) + border(' ┃'));
66
- const choices = widget.choices ?? [];
67
- for (let i = 0; i < choices.length; i++) {
68
- const choice = choices[i];
69
- const line = `[${i + 1}] ${choice.label}`;
70
- for (const wrapped of wrap(line, CONTENT_WIDTH)) {
71
- lines.push(border('┃ ') + padLine(wrapped) + border(' ┃'));
72
- }
73
- }
63
+ lines.push(row(''));
64
+ (widget.choices ?? []).forEach((c, i) => {
65
+ for (const l of wrap(`${i + 1}. ${c.label}`, inner))
66
+ lines.push(row(l));
67
+ });
74
68
  }
75
- lines.push(border('┃ ') + padLine('') + border(' ┃'));
76
69
  lines.push(bottom);
77
70
  return '\n' + lines.join('\n') + '\n';
78
71
  }
@@ -39,6 +39,7 @@
39
39
  * classic REPL path (non-Ink). Ink mode uses its own input component.
40
40
  */
41
41
  import { Transform } from 'node:stream';
42
+ import { markPastedChunk } from './paste-marker.js';
42
43
  /** DEC Private Mode 2004 — enable bracketed paste. */
43
44
  export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h';
44
45
  /** DEC Private Mode 2004 — disable bracketed paste. */
@@ -164,6 +165,8 @@ export class BracketedPasteDecoder extends Transform {
164
165
  // End of stream. If we were still inside a paste (unterminated),
165
166
  // flush the buffered content so it's not lost.
166
167
  if (this.inPaste && this.pasteBuf.length > 0) {
168
+ if (this.emitVerbatim)
169
+ markPastedChunk(this.pasteBuf.toString('utf8'));
167
170
  this.push(this.emitVerbatim ? this.pasteBuf : this.collapseNewlines(this.pasteBuf));
168
171
  this.pasteBuf = Buffer.alloc(0);
169
172
  this.inPaste = false;
@@ -196,8 +199,11 @@ export class BracketedPasteDecoder extends Transform {
196
199
  // `\r` that arrived as its own stdin chunk INSIDE the span was
197
200
  // accumulated here instead of reaching Ink's parseKeypress,
198
201
  // which would have flagged it key.return and submitted.
199
- if (this.pasteBuf.length > 0)
202
+ if (this.pasteBuf.length > 0) {
203
+ // Piece 2 — tell App this chunk was pasted, not typed (the `?` trigger).
204
+ markPastedChunk(this.pasteBuf.toString('utf8'));
200
205
  this.push(this.pasteBuf);
206
+ }
201
207
  }
202
208
  else {
203
209
  this.push(this.collapseNewlines(this.pasteBuf));
@@ -4,13 +4,22 @@
4
4
  * Extracted from slash-picker.ts (2026-06-11) so the router layer
5
5
  * (src/router/classifier.ts) can distinguish "user typed a builtin with
6
6
  * arguments" (e.g. `/help me`) from "user typed an unknown skill"
7
- * WITHOUT importing the inquirer-backed picker module. Pure data — no
8
- * runtime dependencies.
7
+ * WITHOUT importing the inquirer-backed picker module. Pure data plus one
8
+ * read of the process-wide cost display mode (cost/credits.ts); no
9
+ * inquirer, so the router can import it.
9
10
  */
11
+ import { creditsActive } from '../cost/credits.js';
12
+ export const COST_DESCRIPTION_USD = 'Show this session\'s spend so far ($ and tokens)';
13
+ export const COST_DESCRIPTION_CREDITS = 'Show the credits this session has used so far';
14
+ /** Piece 2 §10: managed customers see credits, never "$" (cost/display-mode.ts). */
15
+ export function costCommandDescription() {
16
+ return creditsActive() ? COST_DESCRIPTION_CREDITS : COST_DESCRIPTION_USD;
17
+ }
10
18
  export const BUILTIN_ENTRIES = [
11
- { name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
12
- { name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
13
- { name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
19
+ // Piece 2: /cancel is not listed — it exists only in the classic loop
20
+ // (repl.tsx), and Esc / Ctrl+C already cancel under Ink.
21
+ { name: '/cost', get description() { return costCommandDescription(); } },
22
+ { name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
14
23
  { name: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
15
24
  { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
16
25
  { name: '/exit', description: 'Exit CSPeach' },
@@ -22,19 +31,21 @@ export const BUILTIN_ENTRIES = [
22
31
  { name: '/recap', description: 'Show the session recap (last session · open tasks · plan progress)' },
23
32
  { name: '/reset', description: 'Reset routing (alias)' },
24
33
  { name: '/rewind', description: 'Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)' },
25
- { name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
34
+ { name: '/ui', description: 'View or change rendering mode (auto / ink), or the look: /ui look panels|legacy' },
26
35
  { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
27
36
  { name: '/transport', description: 'View or set the active transport for this session' },
28
37
  { name: '/safety-mode', description: 'Toggle structured Forge Rule confirmation cards (on / off / status)' },
29
38
  ];
30
39
  /**
31
40
  * Builtin command heads for routing checks. Superset of BUILTIN_ENTRIES:
32
- * `/files` is a real REPL builtin (repl.tsx handles it in both paths) but
33
- * is deliberately absent from the picker catalogue, so it's added here.
41
+ * `/files` is a real REPL builtin (repl.tsx handles it in both paths) and
42
+ * `/cancel` is still handled by the classic loop, but neither is listed in the
43
+ * picker catalogue, so both are added here.
34
44
  */
35
45
  const BUILTIN_HEADS = new Set([
36
46
  ...BUILTIN_ENTRIES.map((e) => e.name),
37
47
  '/files',
48
+ '/cancel',
38
49
  ]);
39
50
  /** True when `head` (e.g. '/help', as typed, any case) is a builtin command. */
40
51
  export function isBuiltinCommandHead(head) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Credential handover gate (L1-6 R1-6, owner decision 2026-09-21).
3
+ *
4
+ * A tool that hands the stored SAP login to a local child process (today only
5
+ * `fiori_preview` start) ALWAYS raises a human prompt the first time per
6
+ * session per alias — even as the first mutating call of a turn, where the
7
+ * Rule 8 batch gate stays silent, and whatever auto_approve says. Later calls
8
+ * for the same alias in the same session do not re-ask.
9
+ *
10
+ * The gate itself lives in agent/tool-dispatch.ts; a tool opts in through
11
+ * `Tool.credentialHandover`. This module only remembers the approvals.
12
+ *
13
+ * Module-scope, per CLI process, never persisted: a new CLI process, or a new
14
+ * session id inside one, asks again. Tests call the reset in beforeEach.
15
+ */
16
+ const approved = new Set();
17
+ function key(sessionId, alias) {
18
+ return `${sessionId ?? '(no session)'}\u0000${alias}`;
19
+ }
20
+ export function isCredentialHandoverApproved(sessionId, alias) {
21
+ return approved.has(key(sessionId, alias));
22
+ }
23
+ export function markCredentialHandoverApproved(sessionId, alias) {
24
+ approved.add(key(sessionId, alias));
25
+ }
26
+ export function __resetCredentialHandoverGateForTests() {
27
+ approved.clear();
28
+ }
@@ -57,6 +57,19 @@ export function noteWriteTransport(trkorr) {
57
57
  return;
58
58
  globalStore.update({ lastWriteTransport: trkorr });
59
59
  }
60
+ /**
61
+ * Footer-only wire (UI fix round, 1.1.20): record a transport a read-only
62
+ * lookup CONFIRMED (sap_transport_for_object found the object locked in one
63
+ * open transport the connected user may write to). Live incident: the user
64
+ * named S4HK903731, the lookup confirmed it, and the footer still read
65
+ * "no transport". Like noteWriteTransport it never touches the session write
66
+ * pointer — the lookup is read-only and must not change where writes go.
67
+ */
68
+ export function noteConfirmedTransport(trkorr) {
69
+ if (!trkorr)
70
+ return;
71
+ globalStore.update({ confirmedTransport: trkorr });
72
+ }
60
73
  /**
61
74
  * Handles the `/transport [<trkorr>|clear]` slash command. Returns user-facing
62
75
  * status text. Mirrors the shape of handleSafetyModeCommand in safety-mode-state.ts.
@@ -34,10 +34,13 @@ export function stripBom(s) {
34
34
  * Call once, immediately after readline creation, BEFORE any awaits —
35
35
  * piped data starts flowing on the next tick.
36
36
  */
37
- export function attachEarlyLineBuffer(rl) {
37
+ export function attachEarlyLineBuffer(rl, input) {
38
38
  const queue = [];
39
39
  let firstLine = true;
40
- let isClosed = false;
40
+ // Round 8 item 3 — the piped-prompt probe (lib/piped-prompt.ts) can see an
41
+ // EMPTY stdin reach EOF before readline exists; readline then never emits
42
+ // 'close'. An input that already ended counts as closed from the start.
43
+ let isClosed = input?.readableEnded === true;
41
44
  rl.on('line', (l) => {
42
45
  let line = String(l);
43
46
  if (firstLine) {
@@ -139,6 +139,25 @@ function textActionsFor() {
139
139
  { label: '/abap-explain (plain-language explanation)', command: '/abap-explain' },
140
140
  ];
141
141
  }
142
+ /**
143
+ * Three discrete outcomes a `/files` invocation can produce. The caller
144
+ * (REPL handler) needs to distinguish them because the right UX differs:
145
+ * - 'command' — user picked a file + action; REPL dispatches it.
146
+ * - 'empty' — workspace has no files; REPL prints a helpful
147
+ * listing ("drop something here to start").
148
+ * - 'cancelled' — user pressed Cancel or Ctrl+C; REPL prints NOTHING
149
+ * and returns to the main prompt cleanly.
150
+ *
151
+ * Earlier revision returned `string | null` for all three, which
152
+ * collapsed 'empty' and 'cancelled' into the same null path — the REPL
153
+ * then dumped the full file list every time the user cancelled. This
154
+ * tagged result removes the conflation.
155
+ */
156
+ /** Action menu for a picked file (shared by the inquirer and Ink paths). */
157
+ function actionsForFile(file) {
158
+ const designShape = file.artefactType === 'design' ? detectDesignShape(file.path) : 'unknown';
159
+ return file.kind === 'text' ? textActionsFor() : envelopeActionsFor(file.artefactType, designShape);
160
+ }
142
161
  /**
143
162
  * Open the picker. Returns one of three tagged outcomes — see
144
163
  * `FilePickerResult`. Caller is responsible for dispatching the
@@ -153,11 +172,15 @@ function textActionsFor() {
153
172
  * slips through and exits the REPL silently. One guard around the
154
173
  * whole flow avoids that gap.
155
174
  */
156
- export async function openFilePicker() {
175
+ export async function openFilePicker(opts = {}) {
157
176
  const files = await listProjectFiles();
158
177
  if (files.length === 0) {
159
178
  return { kind: 'empty' };
160
179
  }
180
+ // Piece 2 §6.4 — under Ink, cards in the card slot; inquirer never runs
181
+ // while Ink owns the terminal. Same result shapes as the inquirer flow.
182
+ if (opts.ink)
183
+ return openFilePickerInk(files);
161
184
  try {
162
185
  return await withInquirer(async () => {
163
186
  // Step 1 — pick a file.
@@ -186,17 +209,8 @@ export async function openFilePicker() {
186
209
  const pickedFile = files.find((f) => f.filename === fileResult);
187
210
  if (!pickedFile)
188
211
  return { kind: 'cancelled' };
189
- // Step 2 — pick an action for the file. Action menu depends on
190
- // file kind: envelopes get promote/inspect, text files get the
191
- // skill-input commands that ingest the file contents. For design
192
- // envelopes, additionally inspect the object list to pick the
193
- // right BUILD skill recommendation (RAP vs classic).
194
- const designShape = pickedFile.artefactType === 'design'
195
- ? detectDesignShape(pickedFile.path)
196
- : 'unknown';
197
- const actionsList = pickedFile.kind === 'text'
198
- ? textActionsFor()
199
- : envelopeActionsFor(pickedFile.artefactType, designShape);
212
+ // Step 2 — pick an action for the file (see actionsForFile).
213
+ const actionsList = actionsForFile(pickedFile);
200
214
  const action = await select({
201
215
  message: `What with ${pickedFile.filename}?`,
202
216
  choices: [
@@ -217,3 +231,31 @@ export async function openFilePicker() {
217
231
  return { kind: 'cancelled' };
218
232
  }
219
233
  }
234
+ async function openFilePickerInk(files) {
235
+ const { withFocusClaim } = await import('../ui/card-slot.js');
236
+ const { filesCardEmitter } = await import('../ui/files-card-emitter.js');
237
+ const { askConfirm } = await import('../ui/confirm-request.js');
238
+ const picked = await withFocusClaim(() => filesCardEmitter.requestPick({
239
+ id: 'files',
240
+ title: 'Pick a project file',
241
+ items: files.map((f) => ({ value: f.filename, label: f.filename, detail: `${f.artefactType} v${f.version ?? '?'}` })),
242
+ }));
243
+ if (picked.value == null)
244
+ return { kind: 'cancelled' };
245
+ const pickedFile = files.find((f) => f.filename === picked.value);
246
+ if (!pickedFile)
247
+ return { kind: 'cancelled' };
248
+ const res = await askConfirm({
249
+ id: 'files-action',
250
+ tag: 'QUESTION',
251
+ title: `What with ${pickedFile.filename}?`,
252
+ choices: [
253
+ ...actionsForFile(pickedFile).map((a) => ({ value: a.command, label: a.label })),
254
+ { value: CANCEL_VALUE, label: 'Cancel' },
255
+ ],
256
+ initial: 0,
257
+ }, { trace: false });
258
+ if (res.value == null || res.value === CANCEL_VALUE)
259
+ return { kind: 'cancelled' };
260
+ return { kind: 'command', command: `${res.value} @${pickedFile.filename}` };
261
+ }
@@ -41,6 +41,7 @@
41
41
  * path (no regression, same as the classic-mode caveat).
42
42
  */
43
43
  import { BracketedPasteDecoder } from './bracketed-paste.js';
44
+ import { splitControlKeys } from '../ui/key-burst.js';
44
45
  export class InkStdinPasteGuard extends BracketedPasteDecoder {
45
46
  /**
46
47
  * Mirror the REAL stdin's TTY-ness (the base class hardcodes true for
@@ -52,6 +53,71 @@ export class InkStdinPasteGuard extends BracketedPasteDecoder {
52
53
  constructor() {
53
54
  super({ emitVerbatim: true });
54
55
  }
56
+ /** Keys split off a coalesced control-key chunk, handed out one per read(). */
57
+ keyQueue = [];
58
+ /**
59
+ * Fix round 3 (item 6) — ConPTY coalesces held Backspace / arrow repeats
60
+ * into ONE read, and Ink parses a chunk as ONE key (three Lefts moved the
61
+ * cursor once; DEL bytes were inserted as text). Ink drains with a
62
+ * `while (read() !== null)` loop (App.js handleReadable), so returning one
63
+ * key per read() gives every key its own useInput event.
64
+ */
65
+ /**
66
+ * Round 4 (item 6) — Ink drained a whole split burst in ONE synchronous
67
+ * loop, so 80+ keys meant 80+ nested React updates and React printed
68
+ * "Maximum update depth exceeded" into the transcript. The guard now yields
69
+ * between keys: after a burst key it ends the drain (read() → null) and
70
+ * re-signals 'readable' on the next macrotask, so React commits each key
71
+ * before the next one. Every key still reaches Ink as its own event.
72
+ */
73
+ yieldNext = false;
74
+ resumeScheduled = false;
75
+ scheduleResume() {
76
+ if (this.resumeScheduled)
77
+ return;
78
+ this.resumeScheduled = true;
79
+ setImmediate(() => {
80
+ this.resumeScheduled = false;
81
+ if (!this.destroyed)
82
+ this.emit('readable');
83
+ });
84
+ }
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ read(size) {
87
+ // Round 6 item 1 — Node's Readable calls read(0) itself (to refill the
88
+ // buffer when new data arrives, e.g. typing right after a burst) and
89
+ // DISCARDS the result. Handing it a queued key threw that key away: one
90
+ // Backspace of the burst was lost. read(0) never takes a key or data.
91
+ if (size === 0)
92
+ return super.read(0);
93
+ if (this.keyQueue.length > 0) {
94
+ if (this.yieldNext) {
95
+ this.yieldNext = false;
96
+ this.scheduleResume();
97
+ return null;
98
+ }
99
+ this.yieldNext = true;
100
+ return this.keyQueue.shift();
101
+ }
102
+ if (this.yieldNext) {
103
+ // The burst's last key was just handed out: let React commit it
104
+ // before anything that arrived after the burst.
105
+ this.yieldNext = false;
106
+ if (this.readableLength > 0) {
107
+ this.scheduleResume();
108
+ return null;
109
+ }
110
+ }
111
+ const chunk = super.read(size);
112
+ if (typeof chunk !== 'string')
113
+ return chunk;
114
+ const keys = splitControlKeys(chunk);
115
+ if (keys.length <= 1)
116
+ return chunk;
117
+ this.keyQueue = keys.slice(1);
118
+ this.yieldNext = true;
119
+ return keys[0];
120
+ }
55
121
  /** Ink calls stdin.ref()/unref() around raw-mode enable/disable. */
56
122
  ref() {
57
123
  process.stdin.ref?.();
@@ -26,7 +26,14 @@
26
26
  * function — no pause, no suppress — which is safe because there is no
27
27
  * main readline to disturb.
28
28
  */
29
- import { clearActiveSpinner } from '../renderer/tool-widget.js';
29
+ import { suspendActiveSpinner } from '../renderer/tool-widget.js';
30
+ import { resetTtyStdin } from './reset-tty-stdin.js';
31
+ /**
32
+ * Live fix 2026-09-26 (fix 8) — true while the REPL readline's input is
33
+ * unpiped from stdin for an inquirer prompt (sequence). Re-piped by the
34
+ * final release.
35
+ */
36
+ let inputDetached = false;
30
37
  let state = null;
31
38
  /**
32
39
  * UX-5 #4 (2026-06-12) — sequence-scoped (debounced) release.
@@ -66,6 +73,12 @@ export const RELEASE_DEBOUNCE_MS = 50;
66
73
  let pendingRelease = null;
67
74
  /** Re-entrancy depth — release is scheduled only when the OUTERMOST call exits. */
68
75
  let activeDepth = 0;
76
+ /**
77
+ * Piece 2 §11.3 — one spinner resume per prompt of the current sequence, all
78
+ * fired by the sequence's release (symmetric suspend/resume, fix round 1: a
79
+ * spinner replaced between prompts is resumed too, not left suspended).
80
+ */
81
+ let spinnerResumes = [];
69
82
  function cancelPendingRelease() {
70
83
  if (pendingRelease) {
71
84
  clearTimeout(pendingRelease);
@@ -77,13 +90,16 @@ function cancelPendingRelease() {
77
90
  * callback must flip the same boolean the readline 'close' handler
78
91
  * inspects — otherwise the guard has no effect.
79
92
  */
80
- export function registerReadline(rl, setSuppress) {
81
- state = { rl, setSuppress };
93
+ export function registerReadline(rl, setSuppress, opts = {}) {
94
+ state = { rl, setSuppress, input: opts.input, stdin: opts.stdin ?? process.stdin };
95
+ inputDetached = false;
82
96
  }
83
97
  /** Clear the guard — call when the REPL is shutting down. */
84
98
  export function unregisterReadline() {
85
99
  cancelPendingRelease();
86
100
  activeDepth = 0;
101
+ spinnerResumes = [];
102
+ inputDetached = false;
87
103
  state = null;
88
104
  }
89
105
  /**
@@ -106,18 +122,30 @@ export function unregisterReadline() {
106
122
  export async function withInquirer(fn) {
107
123
  if (!state)
108
124
  return fn();
109
- const { rl, setSuppress } = state;
110
- // 2026-05-01: silence any active tool-dispatch spinner so it doesn't fight
125
+ const { rl, setSuppress, input, stdin } = state;
126
+ // 2026-05-01: pause any active tool-dispatch spinner so it doesn't fight
111
127
  // inquirer for stdout/cursor. The spinner uses \r + erase-line every 80ms,
112
128
  // which collides with inquirer's prompt rendering — visible as ghost-text
113
129
  // and a cursor that hides under the rotating frame while the user types.
114
- clearActiveSpinner();
130
+ // Piece 2 §11.3: SUSPEND (not stop) — the tool is still running after the
131
+ // prompt, so the spinner comes back when the terminal is released.
132
+ spinnerResumes.push(suspendActiveSpinner());
115
133
  // UX-5 #4 — continuing an ask SEQUENCE: a release from the previous
116
134
  // prompt is still pending, so cancel it. The readline stays parked
117
135
  // (paused + close-suppressed) across the gap — no stale-line repaint
118
136
  // between consecutive prompts.
119
137
  cancelPendingRelease();
120
138
  activeDepth++;
139
+ // Live fix 2026-09-26 (fix 8) — detach the REPL readline's input from
140
+ // stdin for the prompt. rl.pause() only stops the decoder's READABLE side:
141
+ // stdin kept writing into it, so every key typed into the prompt was
142
+ // buffered there and replayed into the REPL line on release (echoed onto
143
+ // the spinner line: `⠋ thinking…https://…`). Done BEFORE the listener
144
+ // snapshot below so the pipe's own 'data' handler is never re-attached.
145
+ if (input && !inputDetached) {
146
+ stdin.unpipe(input);
147
+ inputDetached = true;
148
+ }
121
149
  // 2026-05-08: snapshot stdin 'data' / 'keypress' listeners BEFORE the
122
150
  // inquirer prompt runs. Some inquirer widgets (notably `search`, used
123
151
  // by /files step 1) strip ALL listeners on cleanup — including
@@ -125,8 +153,8 @@ export async function withInquirer(fn) {
125
153
  // rl.question() shows its prompt with terminal echo on but ignores
126
154
  // Enter (no input handler is wired). Surfaced after Diff 8 stopped
127
155
  // hiding this with an immediate "Goodbye" exit.
128
- const beforeData = process.stdin.listeners('data').slice();
129
- const beforeKeypress = process.stdin.listeners('keypress').slice();
156
+ const beforeData = stdin.listeners('data').slice();
157
+ const beforeKeypress = stdin.listeners('keypress').slice();
130
158
  setSuppress(true);
131
159
  rl.pause();
132
160
  // 2026-05-08: explicitly engage raw mode BEFORE the inquirer prompt
@@ -146,9 +174,15 @@ export async function withInquirer(fn) {
146
174
  // intends — matching what every working "consecutive inquirer prompt"
147
175
  // path already does (e.g. /files step 1 → step 2). rl.resume() in
148
176
  // finally will re-engage readline keypress mode on its own.
149
- if (process.stdin.isTTY && !process.stdin.isRaw) {
150
- process.stdin.setRawMode(true);
151
- }
177
+ //
178
+ // Live fix 2026-09-26 (fix 8): `setRawMode(true)` alone is not enough. The
179
+ // previous inquirer prompt's readline.close() did setRawMode(false) +
180
+ // pause(), and our finally resume()d — so a COOKED console read is pending,
181
+ // and on Windows it survives setRawMode(true) (isRaw even reports true):
182
+ // keys were held until Enter ("press Enter first, then type"). The fix 1
183
+ // pty experiments showed only the full cycle ends that read. It is
184
+ // harmless when stdin was fine: it ends raw + flowing, as before.
185
+ resetTtyStdin(stdin);
152
186
  try {
153
187
  return await fn();
154
188
  }
@@ -157,16 +191,16 @@ export async function withInquirer(fn) {
157
191
  // didn't replace. The has() check avoids double-binding any
158
192
  // listener inquirer DID restore (different instance is OK; the
159
193
  // same function reference is the only thing we re-add).
160
- const afterData = new Set(process.stdin.listeners('data'));
194
+ const afterData = new Set(stdin.listeners('data'));
161
195
  for (const lf of beforeData) {
162
196
  if (!afterData.has(lf)) {
163
- process.stdin.on('data', lf);
197
+ stdin.on('data', lf);
164
198
  }
165
199
  }
166
- const afterKeypress = new Set(process.stdin.listeners('keypress'));
200
+ const afterKeypress = new Set(stdin.listeners('keypress'));
167
201
  for (const lf of beforeKeypress) {
168
202
  if (!afterKeypress.has(lf)) {
169
- process.stdin.on('keypress', lf);
203
+ stdin.on('keypress', lf);
170
204
  }
171
205
  }
172
206
  // Order matters here:
@@ -181,13 +215,22 @@ export async function withInquirer(fn) {
181
215
  // 3. inside the deferred release, still defer the suppress-clear by
182
216
  // one tick so the spurious 'close' that inquirer synchronously
183
217
  // emits on release is ignored.
184
- process.stdin.resume();
218
+ stdin.resume();
185
219
  activeDepth = Math.max(0, activeDepth - 1);
186
220
  if (activeDepth === 0) {
187
221
  cancelPendingRelease();
188
222
  pendingRelease = setTimeout(() => {
189
223
  pendingRelease = null;
224
+ // Fix 8: hand stdin back to the REPL readline (only NEW keys from here).
225
+ if (input && inputDetached) {
226
+ inputDetached = false;
227
+ stdin.pipe(input);
228
+ }
190
229
  rl.resume();
230
+ const resumes = spinnerResumes;
231
+ spinnerResumes = [];
232
+ for (const resumeSpinner of resumes)
233
+ resumeSpinner();
191
234
  process.nextTick(() => setSuppress(false));
192
235
  }, RELEASE_DEBOUNCE_MS);
193
236
  }