@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
package/dist/ui/app.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback, useRef } from 'react';
3
- import { Box, useInput } from 'ink';
4
- import crypto from 'node:crypto';
3
+ import chalk from 'chalk';
4
+ import { Box, Text, useInput, useStdout } from 'ink';
5
+ import { roleProps } from './role-props.js';
6
+ import { IDLE_EXIT_WINDOW_MS } from './interrupt.js';
5
7
  import { Body } from './body.js';
6
8
  import { Footer } from './footer.js';
7
9
  import { ApprovalModal } from './approval-modal.js';
@@ -9,63 +11,61 @@ import { CommandPalette } from './command-palette.js';
9
11
  import { FilePalette } from './file-palette.js';
10
12
  import { nextResolutionStep, applySlashPick } from './line-resolution.js';
11
13
  import { TurnStatus } from './turn-status.js';
12
- import { StatusLine } from './status-line.js';
14
+ import { FooterLine } from './footer-line.js';
15
+ import { ShortcutsSheet } from './widgets/shortcuts-sheet.js';
16
+ import { wasPasted } from '../repl/paste-marker.js';
13
17
  import { turnStatusEmitter } from './turn-status-emitter.js';
18
+ import { pickSlotOccupant, liveBudget, SLOT_ROWS_ESTIMATE, PALETTE_ROWS } from './live-area.js';
19
+ import { Checklist } from './checklist.js';
20
+ import { formatChecklist, isPlanFinished, CHECKLIST_MAX_ROWS } from '../renderer/checklist-format.js';
14
21
  import { listWorkspaceFileChoices } from '../repl/at-picker.js';
15
- import { RichErrorBox } from './rich-error-box.js';
16
22
  import { SessionTimeline, toTimelineEntry } from './session-timeline.js';
17
- import { TodoPanel } from './todo-panel.js';
18
23
  import { RewindPanel } from './rewind-panel.js';
19
24
  import { rewindEmitter } from './rewind-emitter.js';
20
25
  import { todoEmitter } from './todo-emitter.js';
21
26
  import { approvalEmitter } from './approval-emitter.js';
22
- import { errorEmitter } from './error-emitter.js';
23
27
  import { SapStateContext, globalStore } from './sap-state-store.js';
24
28
  import { coachingPickerEmitter } from './coaching-picker-emitter.js';
25
29
  import { CoachingPicker } from './widgets/coaching-picker.js';
26
30
  import { askQuestionEmitter } from './ask-question-emitter.js';
27
- import { AskQuestionModal } from './widgets/ask-question-modal.js';
28
- import { AskForm } from './widgets/ask-form.js';
31
+ import { AskCard, estimateAskCardRows } from './widgets/ask-card.js';
32
+ import { ConfirmCard, estimateConfirmCardRows } from './widgets/confirm-card.js';
33
+ import { cardSlotEmitter } from './card-slot.js';
34
+ import { useCardRequest } from './use-card-request.js';
35
+ import { FilesCard } from './widgets/files-card.js';
36
+ import { filesCardEmitter } from './files-card-emitter.js';
37
+ import { uiWidth } from '../renderer/ui-width.js';
29
38
  import { runModeCommand } from '../repl/mode-cycle.js';
30
39
  import { enqueueSteering } from '../agent/steering-queue.js';
40
+ import { steeringEchoLine, classifyMidTurnLine, midTurnSlashHint } from '../renderer/steering-echo.js';
41
+ // Moved to renderer/steering-echo.ts (piece 2 I1); re-exported for existing importers.
42
+ export { STEERING_ECHO_MAX, formatSteeringEcho } from '../renderer/steering-echo.js';
31
43
  /**
32
- * Wave1 fast-follow — steering-echo truncation. The `↳ queued ▸` line
33
- * used to echo the ENTIRE queued text; a queued multiline paste dumped a
34
- * whole banner into scrollback (observed live). Show only the first line,
35
- * clipped to STEERING_ECHO_MAX chars, with `… (+N lines)` when more lines
36
- * were queued (`…` alone when just the first line was clipped). Exported
37
- * for the format-pinning test.
44
+ * I1 (spec §7) — slot occupants that ASK the user something. While one of
45
+ * these waits, the input box reads "› (answer above to continue)". The
46
+ * timeline, rewind and shortcuts panels also disable the box, but show no
47
+ * words.
38
48
  */
39
- export const STEERING_ECHO_MAX = 60;
40
- export function formatSteeringEcho(text) {
41
- const lines = text.split('\n');
42
- const first = lines[0] ?? '';
43
- const extra = lines.length - 1;
44
- const clipped = first.length > STEERING_ECHO_MAX;
45
- let out = clipped ? first.slice(0, STEERING_ECHO_MAX) : first;
46
- if (clipped || extra > 0)
47
- out += '…';
48
- if (extra > 0)
49
- out += ` (+${extra} line${extra === 1 ? '' : 's'})`;
50
- return out;
49
+ const ASKING_OCCUPANTS = new Set(['ask', 'confirm', 'approval', 'coaching', 'files']);
50
+ export function isAskingOccupant(occupant) {
51
+ return ASKING_OCCUPANTS.has(occupant);
51
52
  }
52
53
  /**
53
- * App — inline-mode Ink frame (Phase D6, 2026-05-17). After the
54
- * alt-screen detour, reverted to CC's architecture:
54
+ * App — inline-mode Ink frame. Live area, top to bottom (CLI look piece 2,
55
+ * spec §2 — the Claude Code layout):
55
56
  *
56
- * (startup banner) ← printed once by repl.tsx, becomes scrollback
57
- * <Body chunkEmitter=… /> ← <Static> commits each node, becomes scrollback
58
- * {coachingPending && …} ← inline overlays that mount above input
59
- * {askQuestionPending && …}
60
- * <TurnStatus /> ← dynamic, live turn progress
61
- * <StatusLine /> ← dynamic, persistent SAP statusline (Task 4)
62
- * <Footer /> ← dynamic, re-renders at cursor (sticky-ish)
57
+ * <Body /> ← <Static> scrollback (unchanged)
58
+ * <Checklist /> ← live plan checklist (P2, spec §4)
59
+ * card slot ← ONE of approval / confirm / ask / coaching / files /
60
+ * rewind / timeline / shortcuts (pickSlotOccupant)
61
+ * <TurnStatus /> ← only while working and no card waits
62
+ * <Footer /> ← the input box
63
+ * <FooterLine /> ← system · user · transport · mode · spend, `? shortcuts`
64
+ * palettes ← `/` commands, `@` files — under the footer
63
65
  *
64
- * No alt-screen, no termHeight, no PgUp scroll state. The terminal's
65
- * native scrollback handles all of it — mouse wheel, PgUp, search,
66
- * everything just works because we render inline like a normal CLI.
66
+ * The terminal's native scrollback handles everything above the live area.
67
67
  */
68
- export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, getTodos, getRewindCandidates, onRewindRestore, sapRole, sapAlias, onInterrupt }) {
68
+ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, getTodos, getRewindCandidates, onRewindRestore, sapRole, sapAlias, onInterrupt, onExitRequest }) {
69
69
  const [pendingApproval, setPendingApproval] = useState(null);
70
70
  // Task 3 review I1(b): synchronous mirror + monotonic remount key — the same
71
71
  // I2 treatment ask-question already has. A second approval request arriving
@@ -81,8 +81,8 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
81
81
  // lag an arrival that happened during the await).
82
82
  const coachingPendingRef = useRef(null);
83
83
  // UX Wave 2 / Task 2 — discriminated union: v1 single-question requests
84
- // render the untouched <AskQuestionModal>; v2 form requests ('questions'
85
- // in req) render <AskForm>. All the input gates below only check
84
+ // and v2 form requests ('questions' in req) both render <AskCard>
85
+ // (piece 2 / Q2). All the input gates below only check
86
86
  // truthiness, so both shapes suppress the global handlers identically.
87
87
  const [askQuestionPending, setAskQuestionPending] = useState(null);
88
88
  // Review fix (I2): synchronous mirror of the pending request so a second
@@ -90,8 +90,8 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
90
90
  // the first one — otherwise the first tool call's promise leaks and
91
91
  // hangs forever.
92
92
  const askQuestionPendingRef = useRef(null);
93
- // Review fix (I2, + T2 rider): monotonic counter keying <AskForm> AND
94
- // <AskQuestionModal> so a replacing request REMOUNTS the widget instead
93
+ // Review fix (I2, + T2 rider): monotonic counter keying <AskCard>
94
+ // (v1 and forms) so a replacing request REMOUNTS the widget instead
95
95
  // of React reusing the instance with stale state (v1→v1 overlap had the
96
96
  // same stale-state exposure as v2→v2). Deliberately NOT the formId /
97
97
  // question id (collision caveat — two requests may legitimately share
@@ -104,13 +104,15 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
104
104
  // Footer via `prefill`. Replaces the old bare-`/`-only palette flow.
105
105
  const [lineResolution, setLineResolution] = useState(null);
106
106
  const [prefill, setPrefill] = useState({ text: '', nonce: 0 });
107
- const [errorStack, setErrorStack] = useState([]);
108
107
  const [showTimeline, setShowTimeline] = useState(false);
109
- // Task 5 (ux-wave2) — Ctrl+T todo panel + the todoEmitter-fed list.
110
- // Seeded from getTodos() so a RESUMED session shows its persisted list
111
- // before any todo_set fires in this process.
112
- const [showTodoPanel, setShowTodoPanel] = useState(false);
108
+ // Piece 2 I2 (spec §8) — the `?` shortcuts sheet (card-slot occupant 'shortcuts').
109
+ const [showShortcuts, setShowShortcuts] = useState(false);
110
+ // P2 (spec §4) — the live checklist. Seeded from getTodos() so --resume
111
+ // shows the persisted plan before any todo_set fires in this process; a
112
+ // plan that was already finished stays closed.
113
113
  const [todos, setTodos] = useState(() => getTodos?.() ?? []);
114
+ const [planClosed, setPlanClosed] = useState(() => isPlanFinished(getTodos?.() ?? []));
115
+ const [checklistExpanded, setChecklistExpanded] = useState(false);
114
116
  // Task 3 (ux-wave3) — the Esc-Esc / /rewind panel + its lazily-loaded
115
117
  // candidate list. Candidates come from an ASYNC accessor (snapshots.list
116
118
  // reads fs), so the open handler loads them BEFORE flipping the panel on —
@@ -120,6 +122,24 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
120
122
  // Two-Esc chord detector: timestamp of the last lone Esc seen at idle.
121
123
  const lastEscRef = useRef(0);
122
124
  const [footerValue, setFooterValue] = useState('');
125
+ // Piece 2 F3 — terminal size for the live-area height budget (spec §2).
126
+ // ink-testing-library reports no rows, so 24 (the Review Focus 1 case) is the fallback.
127
+ const { stdout } = useStdout();
128
+ const columns = stdout?.columns ?? 80;
129
+ const rows = stdout?.rows ?? 24;
130
+ // P2 (F3 review carry-over) — Ink's useStdout does not re-render on a
131
+ // terminal resize, so rows/columns/budget would go stale. Bump a state value
132
+ // on 'resize' so this render reads the new size.
133
+ const [, setResizeTick] = useState(0);
134
+ useEffect(() => {
135
+ if (!stdout)
136
+ return;
137
+ const onResize = () => setResizeTick((n) => n + 1);
138
+ stdout.on('resize', onResize);
139
+ return () => { stdout.off('resize', onResize); };
140
+ }, [stdout]);
141
+ // The wrapped input's WANTED height. I1 feeds it from the Footer.
142
+ const [inputLines, setInputLines] = useState(1);
123
143
  // Task 7 (ux-wave1) — continuation-buffer depth mirrored up from the
124
144
  // Footer. While a multiline draft is pending the input line is EMPTY
125
145
  // between continuation lines, so every footerValue === '' live trigger
@@ -128,15 +148,15 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
128
148
  // all live triggers stay dormant while the buffer is non-empty.
129
149
  const [footerBufferCount, setFooterBufferCount] = useState(0);
130
150
  // Wave-2 review I-1: model-driven overlays (approval / ask-question /
131
- // coaching) must own input EXCLUSIVELY. The todo panel opens MID-TURN by
132
- // design, so a request can arrive while it (or the ? timeline) is up —
151
+ // coaching) must own input EXCLUSIVELY. A passive panel (the ? timeline,
152
+ // the rewind panel) can be up when a request arrives —
133
153
  // leaving BOTH useInputs active. Ink delivers every keypress to every
134
154
  // active subscriber (no stopPropagation), so one Esc would close the
135
155
  // panel AND fire the approval modal's onCancel — silently declining a
136
156
  // pending write; `q` typed into a free-text ask-form answer would close
137
157
  // the panel behind it; and panel + modal stacked can overflow a 24-row
138
158
  // terminal. Fix at the arrival site: evict the passive panels BEFORE
139
- // mounting the modal. (The user can Ctrl+T / ? again after answering.)
159
+ // mounting the modal. (The user can press ? again after answering.)
140
160
  useEffect(() => {
141
161
  const onReq = (req) => {
142
162
  // I1(b) — a replacing request must not leak the previous producer's
@@ -149,8 +169,8 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
149
169
  }
150
170
  pendingApprovalRef.current = req;
151
171
  setApprovalSeq((s) => s + 1);
152
- setShowTodoPanel(false); // I-1 — evict passive panels, see block comment
153
172
  setShowTimeline(false);
173
+ setShowShortcuts(false);
154
174
  setShowRewindPanel(false); // Task 3 — rewind panel joins the eviction family
155
175
  lastEscRef.current = 0; // M3 — a mounting overlay kills any armed Esc chord
156
176
  setPendingApproval(req);
@@ -163,8 +183,8 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
163
183
  useEffect(() => {
164
184
  const onRequest = (req) => {
165
185
  coachingPendingRef.current = req; // I1(c) — synchronous mirror
166
- setShowTodoPanel(false); // I-1 — same eviction as approval/ask-question
167
186
  setShowTimeline(false);
187
+ setShowShortcuts(false);
168
188
  setShowRewindPanel(false); // Task 3 — rewind panel joins the eviction family
169
189
  lastEscRef.current = 0; // M3 — a mounting overlay kills any armed Esc chord
170
190
  setCoachingPending(req);
@@ -190,8 +210,8 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
190
210
  }
191
211
  askQuestionPendingRef.current = req;
192
212
  setAskQuestionSeq((s) => s + 1);
193
- setShowTodoPanel(false); // I-1 — evict passive panels, see approval block
194
213
  setShowTimeline(false);
214
+ setShowShortcuts(false);
195
215
  setShowRewindPanel(false); // Task 3 — rewind panel joins the eviction family
196
216
  lastEscRef.current = 0; // M3 — a mounting overlay kills any armed Esc chord
197
217
  setAskQuestionPending(req);
@@ -199,29 +219,42 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
199
219
  askQuestionEmitter.on('request', onRequest);
200
220
  return () => { askQuestionEmitter.off('request', onRequest); };
201
221
  }, []);
202
- // Task 5 (ux-wave2) — todo_set fan-out → panel/strip state. Re-seed from
203
- // getTodos AFTER subscribing (TurnStatus snapshot pattern): an update
204
- // fired between mount and this effect would otherwise be missed.
222
+ const confirmSlot = useCardRequest((l) => { cardSlotEmitter.on('confirm', l); return () => { cardSlotEmitter.off('confirm', l); }; }, { value: null, cancelled: true }, () => {
223
+ // Same eviction the approval / ask listeners do on arrival.
224
+ setShowTimeline(false);
225
+ setShowShortcuts(false);
226
+ setShowRewindPanel(false);
227
+ lastEscRef.current = 0;
228
+ });
229
+ // Piece 2 §6.4 — the `/files` search-and-list card (occupant 'files').
230
+ // Same request rules as the confirm slot: a replaced or orphaned pick
231
+ // resolves cancelled, so /files never wedges.
232
+ const filesSlot = useCardRequest((l) => { filesCardEmitter.on('files', l); return () => { filesCardEmitter.off('files', l); }; }, { value: null, cancelled: true }, () => { setShowTimeline(false); setShowShortcuts(false); setShowRewindPanel(false); lastEscRef.current = 0; });
233
+ // P2 (spec §4) — todo_set fan-out → the live checklist. 'update' redraws in
234
+ // place (an empty list hides it); 'close' (turn end, plan finished) hides it
235
+ // until the next 'update'. Re-seed from getTodos AFTER subscribing
236
+ // (TurnStatus snapshot pattern): an update fired between mount and this
237
+ // effect would otherwise be missed.
205
238
  useEffect(() => {
206
- const onUpdate = (t) => setTodos(t);
239
+ const onUpdate = (t) => {
240
+ setTodos(t);
241
+ setPlanClosed(t.length === 0);
242
+ // Fix round 1 — an emptied list (/new, cleared plan) folds Ctrl+T's
243
+ // expand back, so the next plan opens folded.
244
+ if (t.length === 0)
245
+ setChecklistExpanded(false);
246
+ };
247
+ const onClose = () => { setPlanClosed(true); setChecklistExpanded(false); };
207
248
  todoEmitter.on('update', onUpdate);
208
- setTodos(getTodos?.() ?? []);
209
- return () => { todoEmitter.off('update', onUpdate); };
249
+ todoEmitter.on('close', onClose);
250
+ const seeded = getTodos?.() ?? [];
251
+ setTodos(seeded);
252
+ setPlanClosed(isPlanFinished(seeded));
253
+ return () => { todoEmitter.off('update', onUpdate); todoEmitter.off('close', onClose); };
210
254
  // getTodos is stable for the life of the mount (repl.tsx passes a
211
255
  // closure over the session object) — mount-once is intentional.
212
256
  // eslint-disable-next-line react-hooks/exhaustive-deps
213
257
  }, []);
214
- useEffect(() => {
215
- const onError = (err) => {
216
- const id = crypto.randomUUID();
217
- setErrorStack((s) => [...s, { ...err, id }]);
218
- setTimeout(() => {
219
- setErrorStack((s) => s.filter((e) => e.id !== id));
220
- }, 5000);
221
- };
222
- errorEmitter.on('error', onError);
223
- return () => { errorEmitter.off('error', onError); };
224
- }, []);
225
258
  // Task 3 (ux-wave3) — load candidates (async fs read) THEN show the panel, so
226
259
  // it never renders against a stale/empty list. Shared by the Esc-Esc chord and
227
260
  // the /rewind builtin (which signals through rewindEmitter — <App> is a
@@ -254,7 +287,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
254
287
  onSubmit(step.line);
255
288
  return;
256
289
  case 'prefill':
257
- chunkEmitter.emit('chunk', `\x1b[2m${step.hint}\x1b[22m\n`);
290
+ chunkEmitter.emit('chunk', chalk.dim(step.hint) + '\n');
258
291
  setPrefill((p) => ({ text: step.text, nonce: p.nonce + 1 }));
259
292
  return;
260
293
  case 'open-slash':
@@ -284,18 +317,31 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
284
317
  // message at the next round boundary — clear the box, and confirm it
285
318
  // landed. (Was: bounce the input and tell them to wait.)
286
319
  const t = line.trim();
320
+ // Fix round 3 (item 5): slash commands are never steering text.
321
+ const kind = classifyMidTurnLine(t);
322
+ if (kind.kind === 'exit') {
323
+ setPrefill((p) => ({ text: '', nonce: p.nonce + 1 }));
324
+ onExitRequest?.();
325
+ return;
326
+ }
327
+ if (kind.kind === 'slash') {
328
+ // Not sent. The text stays in the input so Enter runs it after the turn.
329
+ chunkEmitter.emit('chunk', midTurnSlashHint(kind.command));
330
+ setPrefill((p) => ({ text: line, nonce: p.nonce + 1 }));
331
+ return;
332
+ }
287
333
  if (t.length > 0) {
288
334
  enqueueSteering(t);
289
335
  // Fast-follow: echo a truncated receipt, not the full (possibly
290
336
  // multiline-pasted) text — the model still gets `t` verbatim.
291
- chunkEmitter.emit('chunk', `\x1b[36m↳ queued ▸ ${formatSteeringEcho(t)}\x1b[39m\n`);
337
+ chunkEmitter.emit('chunk', steeringEchoLine(t));
292
338
  }
293
339
  setPrefill((p) => ({ text: '', nonce: p.nonce + 1 }));
294
340
  return;
295
341
  }
296
342
  const files = listWorkspaceFileChoices();
297
343
  handleStep(nextResolutionStep(line, files), line, files);
298
- }, [handleStep, chunkEmitter]);
344
+ }, [handleStep, chunkEmitter, onExitRequest]);
299
345
  // Palette cancel: restore the user's original line so they can edit it.
300
346
  const cancelResolution = useCallback(() => {
301
347
  const lr = lineResolution;
@@ -304,25 +350,25 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
304
350
  setPrefill((p) => ({ text: lr.original, nonce: p.nonce + 1 }));
305
351
  }
306
352
  }, [lineResolution]);
307
- // `?` key opens the timeline when the footer is empty and no other overlay
308
- // is active.
353
+ // Piece 2 (spec §8) — `?` on an EMPTY input opens the shortcuts sheet in the
354
+ // card slot. Pasted text never triggers it (the paste guard marks paste
355
+ // chunks — repl/paste-marker.ts).
309
356
  //
310
357
  // Keystroke-leak note (Task 5, verified by probe): Ink delivers every
311
358
  // keypress to EVERY active useInput subscriber — there is no
312
359
  // stopPropagation — so a live-trigger character ALSO lands in the Footer's
313
- // TextInput. Before Task 5 the `?` stayed hidden behind the overlay's
314
- // disabled placeholder and reappeared in the input after Esc. The working
315
- // mechanism is an explicit swallow: bump `prefill` with empty text, which
316
- // the Footer applies on the next commit (footer.tsx honours empty prefills
317
- // since Task 5). Every live trigger in this handler family does the same.
360
+ // TextInput. The working mechanism is an explicit swallow: bump `prefill`
361
+ // with empty text, which the Footer applies on the next commit (footer.tsx
362
+ // honours empty prefills since Task 5). Every live trigger in this handler
363
+ // family does the same.
318
364
  useInput((input, _key) => {
319
365
  if (footerBufferCount > 0)
320
366
  return; // Task 7 — mid-composition, stay dormant
321
- if (input === '?' && footerValue === '') {
322
- setShowTimeline(true);
367
+ if (input === '?' && footerValue === '' && !wasPasted(input)) {
368
+ setShowShortcuts(true);
323
369
  setPrefill((p) => ({ text: '', nonce: p.nonce + 1 })); // swallow leaked '?'
324
370
  }
325
- }, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
371
+ }, { isActive: !lineResolution && !showTimeline && !showShortcuts && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending && !confirmSlot.pending && !filesSlot.pending });
326
372
  // Task 5 (ux-wave1) — live `/` opens the command palette immediately
327
373
  // (CC-style), no Enter needed. Fill-in semantics: because the original
328
374
  // line is empty, a pick PREFILLS `/{command} ` instead of running (the
@@ -338,7 +384,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
338
384
  return; // BUG-2b: steering, not palette (covers prep window)
339
385
  setPrefill((p) => ({ text: '', nonce: p.nonce + 1 })); // swallow leaked '/'
340
386
  handleSlashTrigger();
341
- }, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
387
+ }, { isActive: !lineResolution && !showTimeline && !showShortcuts && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending && !confirmSlot.pending && !filesSlot.pending });
342
388
  // Task 6 (ux-wave1) — live `@` opens the file palette with APPEND
343
389
  // semantics: the partial line survives and the picked token lands where
344
390
  // the user was typing (pick prefills `footerValue + '@' + name + ' '`
@@ -374,7 +420,7 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
374
420
  return; // nothing to pick — let `@` land as plain text
375
421
  setPrefill((p) => ({ text: footerValue, nonce: p.nonce + 1 })); // swallow leaked '@', keep the line
376
422
  setLineResolution({ mode: 'at', prefix: '', line: `${footerValue}@`, original: `${footerValue}@`, files });
377
- }, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
423
+ }, { isActive: !lineResolution && !showTimeline && !showShortcuts && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending && !confirmSlot.pending && !filesSlot.pending });
378
424
  // Task 3 (ux-wave1) — Shift+Tab / Alt+M cycle the session write mode.
379
425
  // Same gating as the `?` handler: only when the footer is empty and no
380
426
  // overlay owns input. Windows Terminal sends backtab as CSI Z, which Ink
@@ -401,25 +447,14 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
401
447
  if (!shiftTab && !altM)
402
448
  return;
403
449
  const { line, mode } = runModeCommand(undefined, globalStore.get().writeMode ?? 'approval-gated', sapRole, sapAlias);
404
- chunkEmitter.emit('chunk', `\x1b[2m${line}\x1b[22m\n`);
450
+ chunkEmitter.emit('chunk', chalk.dim(line) + '\n');
405
451
  globalStore.update({ writeMode: mode });
406
452
  // Alt+M's 'm' also lands in the TextInput (see the leak note on the
407
453
  // `?` handler) — swallow it. Backtab is harmless (TextInput ignores
408
454
  // tab), but the swallow is a no-op for it anyway.
409
455
  setPrefill((p) => ({ text: '', nonce: p.nonce + 1 }));
410
- }, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
411
- // Task 5 (ux-wave2) — Ctrl+T opens the todo panel. Same gate family as
412
- // the Wave-1 live triggers (no overlay owns input, footer empty, no
413
- // multiline draft) with ONE deliberate difference: mid-turn is ALLOWED —
414
- // no turnStatusEmitter guard. Peeking at the model's task list WHILE it
415
- // works is the point of the panel (the turn-status strip shows only the
416
- // current item; Ctrl+T shows the whole plan). Closing is the panel's
417
- // job (Esc/q/Ctrl+T in TodoPanel's useInput) — this handler is inactive
418
- // while the panel is up, so the chord toggles instead of double-firing.
419
- // Open re-reads getTodos(): cheap, and correct after a session resume
420
- // (the emitter only fires on new todo_set calls). The leaked 't' is
421
- // swallowed with an empty prefill — same mechanism as `?` / Alt+M
422
- // (footerValue is '' by gate, so the empty prefill is lossless).
456
+ }, { isActive: !lineResolution && !showTimeline && !showShortcuts && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending && !confirmSlot.pending && !filesSlot.pending });
457
+ // P2 — Ctrl+T toggles the full checklist (spec §4).
423
458
  useInput((input, key) => {
424
459
  if (!(key.ctrl && (input === 't' || input === 'T')))
425
460
  return;
@@ -427,10 +462,11 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
427
462
  return;
428
463
  if (footerBufferCount > 0)
429
464
  return; // Task 7 — mid-composition, stay dormant
430
- setTodos(getTodos?.() ?? []);
431
- setShowTodoPanel(true);
465
+ // P2 — Ctrl+T expands / folds the live checklist in place (spec §4).
466
+ // Mid-turn is allowed (watching the plan while it runs is the point).
467
+ setChecklistExpanded((e) => !e);
432
468
  setPrefill((p) => ({ text: '', nonce: p.nonce + 1 })); // swallow leaked 't'
433
- }, { isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending });
469
+ }, { isActive: !lineResolution && !showTimeline && !showShortcuts && !showRewindPanel && !pendingApproval && !coachingPending && !askQuestionPending && !confirmSlot.pending && !filesSlot.pending });
434
470
  // Task 3 (ux-wave3) — Esc-Esc opens the rewind panel. Two Esc within 500ms
435
471
  // at IDLE ONLY. The isActive gate is the full five-gate family (no overlay
436
472
  // owns input, footer empty, no multiline draft) so a single Esc keeps its
@@ -457,15 +493,35 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
457
493
  lastEscRef.current = now;
458
494
  }
459
495
  }, {
460
- isActive: !lineResolution && !showTimeline && !showTodoPanel && !showRewindPanel
461
- && !pendingApproval && !coachingPending && !askQuestionPending
496
+ isActive: !lineResolution && !showTimeline && !showShortcuts && !showRewindPanel
497
+ && !pendingApproval && !coachingPending && !askQuestionPending && !confirmSlot.pending && !filesSlot.pending
462
498
  && footerValue === '' && footerBufferCount === 0,
463
499
  });
464
- // Ctrl+C handler — abort in-flight turn or exit at idle.
500
+ // Ctrl+C handler — abort in-flight turn, or arm / confirm the idle exit.
501
+ // Fix round 3 (item 4): while a card, sheet or palette is open, Ctrl+C
502
+ // belongs to it (each cancels itself like Esc) and never reaches the REPL —
503
+ // it used to exit the whole CLI from the skill picker.
504
+ const overlayOpen = !!pendingApproval || !!coachingPending || !!askQuestionPending
505
+ || confirmSlot.pending !== null || filesSlot.pending !== null
506
+ || !!lineResolution || showTimeline || showShortcuts || showRewindPanel;
507
+ const [exitHint, setExitHint] = useState(false);
508
+ const exitHintTimer = useRef(null);
509
+ useEffect(() => () => { if (exitHintTimer.current)
510
+ clearTimeout(exitHintTimer.current); }, []);
465
511
  useInput((input, key) => {
466
- if (key.ctrl && input === 'c')
467
- onInterrupt?.();
468
- }, { isActive: true });
512
+ if (!(key.ctrl && input === 'c'))
513
+ return;
514
+ const r = onInterrupt?.();
515
+ if (exitHintTimer.current)
516
+ clearTimeout(exitHintTimer.current);
517
+ if (r === 'armed') {
518
+ setExitHint(true);
519
+ exitHintTimer.current = setTimeout(() => setExitHint(false), IDLE_EXIT_WINDOW_MS);
520
+ }
521
+ else {
522
+ setExitHint(false);
523
+ }
524
+ }, { isActive: !overlayOpen });
469
525
  // I1(b) — clear ONLY when this request still owns the slot: a replacing
470
526
  // request (resolved-as-superseded in onReq) may already be pending, and the
471
527
  // old modal's resolve callback must not evict it.
@@ -490,40 +546,134 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
490
546
  .filter((c) => c.completed === true)
491
547
  .map(toTimelineEntry)
492
548
  : [];
493
- return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Body, { chunkEmitter: chunkEmitter }), coachingPending && (_jsx(CoachingPicker, { params: coachingPending.params, onResolve: (result) => {
494
- coachingPending.resolve(result);
495
- if (coachingPendingRef.current === coachingPending) {
496
- coachingPendingRef.current = null; // I1(c) — keep the mirror true
497
- }
498
- setCoachingPending(null);
499
- } })), askQuestionPending && ('questions' in askQuestionPending ? (_jsx(AskForm, { request: askQuestionPending, onResolve: (result) => {
500
- askQuestionPending.resolve(result);
501
- // Only clear when this request is still the pending one — a
502
- // replacing request (I2) may already own the slot.
503
- if (askQuestionPendingRef.current === askQuestionPending) {
504
- askQuestionPendingRef.current = null;
505
- setAskQuestionPending(null);
506
- }
507
- } }, askQuestionSeq)) : (_jsx(AskQuestionModal, { params: askQuestionPending, onResolve: (result) => {
508
- askQuestionPending.resolve(result);
509
- if (askQuestionPendingRef.current === askQuestionPending) {
510
- askQuestionPendingRef.current = null;
511
- setAskQuestionPending(null);
512
- }
513
- } }, askQuestionSeq))), _jsx(TurnStatus, { inProgressTodo: todos.filter((t) => t.status === 'in_progress').at(-1)?.text ?? null }), _jsx(StatusLine, {}), _jsx(Footer, { onSubmit: beginSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, prefill: prefill, onValueChange: setFooterValue, onBufferChange: setFooterBufferCount,
549
+ // Spec §2 / §1.6 — exactly ONE card in the slot.
550
+ // piece 2 / Q2 — clear the slot only while this request is still the
551
+ // pending one; a replacing request (I2) may already own it.
552
+ const clearAskIfCurrent = (req) => {
553
+ if (askQuestionPendingRef.current === req) {
554
+ askQuestionPendingRef.current = null;
555
+ setAskQuestionPending(null);
556
+ }
557
+ };
558
+ const occupant = pickSlotOccupant({
559
+ approval: !!pendingApproval,
560
+ confirm: confirmSlot.pending !== null,
561
+ ask: !!askQuestionPending,
562
+ coaching: !!coachingPending,
563
+ files: filesSlot.pending !== null,
564
+ rewind: showRewindPanel,
565
+ timeline: showTimeline,
566
+ shortcuts: showShortcuts,
567
+ });
568
+ // I1 (spec §7) — "(answer above to continue)" only while the occupant asks.
569
+ const waitingForCard = isAskingOccupant(occupant);
570
+ // Spec §2 height cap: the live area never exceeds rows − 2. Each occupant
571
+ // contributes a fixed estimate. Q2 (ask) and C1 (confirm) replace theirs
572
+ // with a real estimator right below this line. P2 replaces `checklistRows: 0`.
573
+ let cardRows = occupant === null ? 0 : SLOT_ROWS_ESTIMATE[occupant];
574
+ // Q2 — the ask card's full height (descriptions included, so shedding them
575
+ // cannot oscillate; spec §2 fold order: descriptions drop before options).
576
+ if (occupant === 'ask' && askQuestionPending)
577
+ cardRows = estimateAskCardRows(askQuestionPending, true, uiWidth(columns));
578
+ // C1 — the confirm card's real height (facts + choices) for the spec §2 cap.
579
+ if (occupant === 'confirm' && confirmSlot.pending)
580
+ cardRows = estimateConfirmCardRows(confirmSlot.pending);
581
+ const paletteRows = lineResolution ? PALETTE_ROWS : 0;
582
+ // P2 — the checklist's WANTED height (header + rows); liveBudget folds it.
583
+ const showChecklist = todos.length > 0 && !planClosed;
584
+ const checklistWidth = uiWidth(columns);
585
+ const checklistDesiredRows = showChecklist
586
+ ? formatChecklist(todos, {
587
+ width: checklistWidth,
588
+ maxRows: checklistExpanded ? todos.length : CHECKLIST_MAX_ROWS,
589
+ expanded: checklistExpanded,
590
+ foldedToOne: false,
591
+ }).length
592
+ : 0;
593
+ const budget = liveBudget({ rows, checklistRows: checklistDesiredRows, cardRows, inputLines, paletteRows });
594
+ let slot = null;
595
+ if (occupant === 'approval' && pendingApproval) {
596
+ slot = (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
597
+ pendingApproval.resolve({ approved: true });
598
+ clearApproval(pendingApproval);
599
+ }, onSkip: () => {
600
+ pendingApproval.resolve({ approved: false, reason: 'skipped' });
601
+ clearApproval(pendingApproval);
602
+ }, onDiff: () => { }, onCancel: () => {
603
+ pendingApproval.resolve({ approved: false, reason: '__cancel_turn__' });
604
+ clearApproval(pendingApproval);
605
+ }, onInterruptTurn: () => { onInterrupt?.(); } }, approvalSeq) }));
606
+ }
607
+ else if (occupant === 'confirm' && confirmSlot.pending) {
608
+ const req = confirmSlot.pending;
609
+ slot = (_jsx(ConfirmCard, { request: req, width: uiWidth(columns), onResolve: (r) => confirmSlot.finish(req, r), onInterruptTurn: () => { onInterrupt?.(); } }, confirmSlot.seq));
610
+ }
611
+ else if (occupant === 'ask' && askQuestionPending) {
612
+ const req = askQuestionPending;
613
+ slot = (_jsx(AskCard, { request: req, width: uiWidth(columns), showDescriptions: budget.showDescriptions, onResolveV1: (result) => {
614
+ if (!('questions' in req))
615
+ req.resolve(result);
616
+ clearAskIfCurrent(req);
617
+ }, onResolveForm: (result) => {
618
+ if ('questions' in req)
619
+ req.resolve(result);
620
+ clearAskIfCurrent(req);
621
+ }, onInterruptTurn: () => { onInterrupt?.(); } }, askQuestionSeq));
622
+ }
623
+ else if (occupant === 'coaching' && coachingPending) {
624
+ const req = coachingPending;
625
+ slot = (_jsx(CoachingPicker, { params: req.params, width: uiWidth(columns), onResolve: (result) => {
626
+ req.resolve(result);
627
+ if (coachingPendingRef.current === req) {
628
+ coachingPendingRef.current = null; // I1(c) — keep the mirror true
629
+ }
630
+ setCoachingPending(null);
631
+ } }));
632
+ }
633
+ else if (occupant === 'files' && filesSlot.pending) {
634
+ const req = filesSlot.pending;
635
+ slot = (_jsx(FilesCard, { request: req, width: uiWidth(columns), onResolve: (r) => filesSlot.finish(req, r) }, filesSlot.seq));
636
+ }
637
+ else if (occupant === 'rewind') {
638
+ slot = (_jsx(Box, { marginTop: 1, children: _jsx(RewindPanel, { width: uiWidth(columns), candidates: rewindCandidates,
639
+ // Panel confirms, then fires the gated executor (repl.tsx assembles
640
+ // deps + bridges approval to the Ink modal) and closes itself.
641
+ onRestore: (c) => { onRewindRestore?.(c); }, onClose: () => {
642
+ setShowRewindPanel(false);
643
+ // P2 — /rewind restores the checklist from session.todos (spec §4).
644
+ const list = getTodos?.() ?? [];
645
+ setTodos(list);
646
+ setPlanClosed(isPlanFinished(list));
647
+ } }) }));
648
+ }
649
+ else if (occupant === 'timeline') {
650
+ slot = (_jsx(Box, { marginTop: 1, children: _jsx(SessionTimeline, { width: uiWidth(columns), entries: timelineEntries, onClose: () => setShowTimeline(false) }) }));
651
+ }
652
+ else if (occupant === 'shortcuts') {
653
+ slot = _jsx(ShortcutsSheet, { width: uiWidth(columns), onClose: () => setShowShortcuts(false) });
654
+ }
655
+ return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Body, { chunkEmitter: chunkEmitter }), showChecklist && (_jsx(Checklist, { todos: todos, width: checklistWidth, maxRows: Math.max(1, budget.checklistRows - 1), expanded: checklistExpanded,
656
+ // liveBudget folds the checklist to 1 row whenever a card is open (occupant !== null).
657
+ foldedToOne: budget.checklistRows <= 1 })), slot, occupant === null && _jsx(TurnStatus, {}), _jsx(Footer, { onSubmit: beginSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, prefill: prefill, onValueChange: setFooterValue, onBufferChange: setFooterBufferCount, maxLines: budget.inputMaxLines, onInputLinesChange: setInputLines,
658
+ // Spec §7: "(answer above to continue)" only while a card that asks
659
+ // waits — not for the timeline / rewind / shortcuts panels, nor an
660
+ // open `/` / `@` palette (those are not slot occupants).
661
+ waitingForCard: waitingForCard,
514
662
  // Task 7 — multiline announcements (draft discarded) land as
515
663
  // dim scrollback lines, house style for Footer-adjacent notices.
516
- onNotice: (t) => chunkEmitter.emit('chunk', `\x1b[2m${t}\x1b[22m\n`),
664
+ onNotice: (t) => chunkEmitter.emit('chunk', chalk.dim(t) + '\n'),
517
665
  // 2026-05-17 — disable Footer input while any modal owns
518
666
  // input, so keystrokes don't double-fire (modal answer +
519
667
  // spurious Footer submission with same text).
520
668
  disabled: !!coachingPending
521
669
  || !!askQuestionPending
670
+ || confirmSlot.pending !== null
671
+ || filesSlot.pending !== null
522
672
  || !!pendingApproval
523
673
  || !!lineResolution
524
674
  || showTimeline
525
- || showTodoPanel
526
- || showRewindPanel }), lineResolution?.mode === 'slash' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { initialQuery: lineResolution.prefix, onSelect: (name) => {
675
+ || showShortcuts
676
+ || showRewindPanel }), exitHint ? _jsx(Text, { ...roleProps('dim'), children: "Press Ctrl+C again to exit" }) : _jsx(FooterLine, {}), lineResolution?.mode === 'slash' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { initialQuery: lineResolution.prefix, onSelect: (name) => {
527
677
  const lr = lineResolution;
528
678
  setLineResolution(null);
529
679
  // Task 5 — empty original ⇔ palette opened by the live `/`
@@ -537,17 +687,5 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
537
687
  // (e.g. add `--scope ZMM_VENDOR_CHECK` after `--from @<file>`).
538
688
  const resolved = lr.line.replace(/@\S*$/, `@${filename}`);
539
689
  setPrefill((p) => ({ text: `${resolved} `, nonce: p.nonce + 1 }));
540
- }, onCancel: cancelResolution }) })), errorStack.map((e) => (_jsx(Box, { children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })), showTodoPanel && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(TodoPanel, { todos: todos, onClose: () => setShowTodoPanel(false) }) })), showRewindPanel && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(RewindPanel, { candidates: rewindCandidates,
541
- // Panel confirms, then fires the gated executor (repl.tsx assembles
542
- // deps + bridges approval to the Ink modal) and closes itself.
543
- onRestore: (c) => { onRewindRestore?.(c); }, onClose: () => setShowRewindPanel(false) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
544
- pendingApproval.resolve({ approved: true });
545
- clearApproval(pendingApproval);
546
- }, onSkip: () => {
547
- pendingApproval.resolve({ approved: false, reason: 'skipped' });
548
- clearApproval(pendingApproval);
549
- }, onDiff: () => { }, onCancel: () => {
550
- pendingApproval.resolve({ approved: false, reason: '__cancel_turn__' });
551
- clearApproval(pendingApproval);
552
- } }, approvalSeq) }))] }) }));
690
+ }, onCancel: cancelResolution }) }))] }) }));
553
691
  }