@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
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Control-key bursts (fix round 3, item 6).
3
+ *
4
+ * Ink parses each stdin chunk as ONE key. When ConPTY coalesces several key
5
+ * repeats into a single read (held Backspace or arrows while the event loop is
6
+ * busy), the chunk matches no key and arrives as "text" — the DEL bytes were
7
+ * then inserted into the draft as invisible characters. This walks such a
8
+ * chunk key by key and applies each to (value, cursor). It never inserts a
9
+ * control character.
10
+ */
11
+ const ESC = '\u001b';
12
+ /** True when the chunk carries a control character (other than newline / tab). */
13
+ // eslint-disable-next-line no-control-regex
14
+ const HAS_CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/;
15
+ // CSI (ESC [ params final) or SS3 (ESC O final).
16
+ // eslint-disable-next-line no-control-regex
17
+ const ESCAPE_SEQ = /^\u001b(?:\[[0-9;?]*[@-~]|O[@-~])?/;
18
+ const STRIPPED_LEAD = /^(?:\[[0-9;?]*[A-Za-z~]|O[A-Za-z])/;
19
+ /**
20
+ * Remove every control character except newline and tab — the last guard
21
+ * before text lands in a draft (an ESC-bearing paste, a stray NUL).
22
+ */
23
+ export function stripControlChars(s) {
24
+ // eslint-disable-next-line no-control-regex
25
+ // Whole CSI / SS3 sequences first (review M8): dropping only the ESC left
26
+ // a pasted ANSI log reading "[31mred".
27
+ return s
28
+ .replace(/\u001b(?:\[[0-9;?]*[@-~]|O[@-~])/g, '')
29
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
30
+ }
31
+ /**
32
+ * Split a chunk made ONLY of control keys (escape sequences, DEL, BS, other
33
+ * C0 controls) into one string per key. Ink's parser reads a chunk as a
34
+ * single key — "\x1b[D\x1b[D\x1b[D" parses as ONE Left and the rest is lost
35
+ * before useInput sees it — so InkStdinPasteGuard hands Ink one key per
36
+ * read(). Returns [chunk] (no split) when the chunk holds any printable text
37
+ * (text-input's applyKeyBurst handles mixed chunks) or a CR / LF (Enter and
38
+ * non-bracketed pastes keep their existing handling).
39
+ */
40
+ export function splitControlKeys(chunk) {
41
+ if (chunk.length < 2 || chunk.includes('\r') || chunk.includes('\n'))
42
+ return [chunk];
43
+ const keys = [];
44
+ let rest = chunk;
45
+ while (rest.length > 0) {
46
+ const ch = rest[0];
47
+ if (ch === ESC) {
48
+ // eslint-disable-next-line no-control-regex
49
+ const m = /^\u001b(?:\[[0-9;?]*[@-~]|O[@-~])/.exec(rest);
50
+ if (!m)
51
+ return [chunk]; // a lone ESC or Alt+key — leave the chunk alone
52
+ keys.push(m[0]);
53
+ rest = rest.slice(m[0].length);
54
+ continue;
55
+ }
56
+ if (!HAS_CONTROL.test(ch))
57
+ return [chunk]; // printable text — not a pure burst
58
+ keys.push(ch);
59
+ rest = rest.slice(1);
60
+ }
61
+ return keys.length > 1 ? keys : [chunk];
62
+ }
63
+ /**
64
+ * Apply a multi-key chunk. Returns null when the chunk holds no control
65
+ * characters — the caller then inserts it as ordinary text.
66
+ */
67
+ export function applyKeyBurst(value, cursor, input) {
68
+ if (!HAS_CONTROL.test(input))
69
+ return null;
70
+ // Ink strips the chunk's first ESC; put it back when the chunk starts with
71
+ // what is clearly the rest of an escape sequence.
72
+ let rest = STRIPPED_LEAD.test(input) ? ESC + input : input;
73
+ let v = value;
74
+ let c = Math.max(0, Math.min(cursor, value.length));
75
+ while (rest.length > 0) {
76
+ const ch = rest[0];
77
+ if (ch === ESC) {
78
+ const m = ESCAPE_SEQ.exec(rest);
79
+ const seq = m[0];
80
+ const final = seq[seq.length - 1];
81
+ if (seq.length > 1) {
82
+ if (final === 'D')
83
+ c = Math.max(0, c - 1);
84
+ else if (final === 'C')
85
+ c = Math.min(v.length, c + 1);
86
+ else if (final === 'H')
87
+ c = 0;
88
+ else if (final === 'F')
89
+ c = v.length;
90
+ else if (seq === ESC + '[3~' && c > 0) {
91
+ // Delete — same as the single-key path, which cannot tell the
92
+ // Delete key from Backspace (both parse as key.delete).
93
+ v = v.slice(0, c - 1) + v.slice(c);
94
+ c--;
95
+ }
96
+ }
97
+ rest = rest.slice(seq.length || 1);
98
+ continue;
99
+ }
100
+ if (ch === '\u007f' || ch === '\b') {
101
+ if (c > 0) {
102
+ v = v.slice(0, c - 1) + v.slice(c);
103
+ c--;
104
+ }
105
+ rest = rest.slice(1);
106
+ continue;
107
+ }
108
+ if (HAS_CONTROL.test(ch)) {
109
+ rest = rest.slice(1); // any other control character is dropped
110
+ continue;
111
+ }
112
+ v = v.slice(0, c) + ch + v.slice(c);
113
+ c++;
114
+ rest = rest.slice(1);
115
+ }
116
+ return { value: v, cursor: c };
117
+ }
@@ -21,6 +21,7 @@
21
21
  import { isExactSlashMatch, filterSlashEntries } from '../repl/slash-picker.js';
22
22
  import { pendingAtTokenPrefix } from '../repl/at-picker.js';
23
23
  import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
24
+ import { getGlyphs } from '../renderer/glyphs.js';
24
25
  export function nextResolutionStep(line, files) {
25
26
  const trimmed = line.trim();
26
27
  if (trimmed.startsWith('/') && !isExactSlashMatch(trimmed)) {
@@ -65,7 +66,7 @@ export function applySlashPick(pickedName, rest, files, fillIn = false) {
65
66
  return {
66
67
  kind: 'prefill',
67
68
  text: `${name} `,
68
- hint: `↪ ${name} — add arguments or press Enter to run`,
69
+ hint: `${getGlyphs().info} ${name} — add arguments or press Enter to run`,
69
70
  };
70
71
  }
71
72
  return nextResolutionStep(`${name}${rest}`, files);
@@ -92,7 +93,7 @@ function emptyBodyGuard(line) {
92
93
  return {
93
94
  kind: 'prefill',
94
95
  text: `/${aliasTarget} `,
95
- hint: `↪ ${head.toLowerCase()} is now /${aliasTarget} — type your prompt and press Enter`,
96
+ hint: `${getGlyphs().info} ${head.toLowerCase()} is now /${aliasTarget} — type your prompt and press Enter`,
96
97
  };
97
98
  }
98
99
  const isSkill = SKILL_CATALOG.some((s) => `/${s.name}` === head.toLowerCase());
@@ -100,7 +101,7 @@ function emptyBodyGuard(line) {
100
101
  return {
101
102
  kind: 'prefill',
102
103
  text: `${head} `,
103
- hint: `↪ ${head} — type your prompt and press Enter`,
104
+ hint: `${getGlyphs().info} ${head} — type your prompt and press Enter`,
104
105
  };
105
106
  }
106
107
  }
@@ -0,0 +1,71 @@
1
+ // src/ui/live-area.ts
2
+ //
3
+ // Pure layout decisions for the Ink live area (CLI look piece 2, spec §2).
4
+ // Order, top to bottom: Body (<Static>) → checklist → card slot → status
5
+ // strip → input → footer → palettes. Ink 5 clears and redraws the WHOLE
6
+ // screen once the live region reaches the terminal height (ink.js
7
+ // `outputHeight >= stdout.rows`), so the live area is capped at rows − 2.
8
+ /** Precedence: model-driven blocking cards first, then user-opened panels. */
9
+ const PRECEDENCE = ['approval', 'confirm', 'ask', 'coaching', 'files', 'rewind', 'timeline', 'shortcuts'];
10
+ /** The ONE card shown in the slot (spec §1.6: one card at a time), or null. */
11
+ export function pickSlotOccupant(s) {
12
+ for (const k of PRECEDENCE)
13
+ if (s[k])
14
+ return k;
15
+ return null;
16
+ }
17
+ export const STATUS_ROWS = 1;
18
+ export const INPUT_BORDER_ROWS = 2;
19
+ export const FOOTER_ROWS = 1;
20
+ export const INPUT_MAX_LINES = 6;
21
+ /**
22
+ * Fixed height estimates (rows, margins and borders included) for each slot
23
+ * occupant, fed to liveBudget as `cardRows`. They cover the collapsed card.
24
+ * A task that owns a card with a variable height replaces its entry at the
25
+ * App call site with a real estimator (Q2: estimateAskCardRows, C1:
26
+ * estimateConfirmCardRows).
27
+ * approval margin 1 + border 2 + padding 2 + 3 fact rows + margin 1 + keys 1
28
+ * rewind margin 1 + border 2 + title 1 + margin 1 + more 2 + REWIND_VISIBLE_ROWS 9 + margin 1 + hint 1
29
+ * timeline margin 1 + border 2 + title 1 + margin 1 + more 2 + TIMELINE_VISIBLE_ROWS 9
30
+ * (S4a: both cards pin their rendered height to these in their own tests.)
31
+ */
32
+ export const SLOT_ROWS_ESTIMATE = {
33
+ approval: 10,
34
+ confirm: 10,
35
+ ask: 12,
36
+ coaching: 12,
37
+ files: 14,
38
+ rewind: 18,
39
+ timeline: 16,
40
+ shortcuts: 14,
41
+ };
42
+ /** An open `/` or `@` palette: margin 1 + query 1 + ↑ more 1 + 8 rows + ↓ more 1 + hint 1. */
43
+ export const PALETTE_ROWS = 13;
44
+ /**
45
+ * Rows each live element may use so the whole live area stays ≤ rows − 2.
46
+ *
47
+ * Inputs are WANTED heights: `cardRows` is the card's full height INCLUDING
48
+ * option descriptions, `inputLines` the wrapped input's full height. (Passing
49
+ * the already-shrunk height would make descriptions flicker on and off.)
50
+ *
51
+ * Fold order over the cap (spec §2):
52
+ * 1. the checklist folds to its one summary line while a card is open;
53
+ * with no card it may use everything except one input line;
54
+ * 2. the input stops growing: min(inputLines, 6, what is left), never < 1;
55
+ * 3. card option descriptions drop (showDescriptions = false) when not
56
+ * even one input line fits beside the full card.
57
+ * The status row is always counted (conservative: it is hidden while a card
58
+ * is open, so the real frame is one row shorter).
59
+ */
60
+ export function liveBudget(args) {
61
+ const cap = Math.max(0, args.rows - 2);
62
+ const fixed = STATUS_ROWS + INPUT_BORDER_ROWS + FOOTER_ROWS + Math.max(0, args.paletteRows);
63
+ const wanted = Math.max(0, args.checklistRows);
64
+ const card = Math.max(0, args.cardRows);
65
+ const checklistRows = card > 0
66
+ ? Math.min(wanted, 1)
67
+ : Math.min(wanted, Math.max(Math.min(wanted, 1), cap - fixed - 1));
68
+ const remaining = cap - fixed - checklistRows - card;
69
+ const inputMaxLines = Math.max(1, Math.min(Math.max(1, args.inputLines), INPUT_MAX_LINES, remaining));
70
+ return { checklistRows, inputMaxLines, showDescriptions: remaining >= 1 };
71
+ }
@@ -1,66 +1,77 @@
1
1
  /**
2
- * Format the login banner shown at REPL startup. Decision 8: verbose
3
- * with budget + warning. Yellow warning fires when:
4
- * - token usage ≥ 80% of input OR output cap (managed-capped trials only)
2
+ * Login banner (REPL startup). Decision 8: verbose with budget + warning.
3
+ * The warning fires when:
4
+ * - token usage ≥ 80% of the input cap (managed-capped trials only)
5
5
  * - trial expiry < 24 hours away
6
6
  * - trial already expired
7
+ * Piece 2 §10: warning TEXT carries no glyph — callers print it through
8
+ * formatAttention ("!"). signInFragment() is the panels brand-line fold-in.
7
9
  */
10
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
8
11
  const MS_PER_HOUR = 3600 * 1000;
9
12
  const MS_PER_DAY = 24 * MS_PER_HOUR;
10
- export function formatLoginBanner(profile) {
11
- // Non-trial path.
12
- if (profile.trial_mode === null) {
13
- return {
14
- line: `Signed in as ${profile.email} (${profile.plan})`,
15
- warningLine: null,
16
- };
17
- }
18
- // Trial — figure out time left.
19
- const now = Date.now();
13
+ function trialTime(profile, now) {
20
14
  const ends = profile.trial_ends_at ? new Date(profile.trial_ends_at).getTime() : now;
21
15
  const msLeft = ends - now;
22
- let timeFragment;
23
- let isExpired = false;
24
- if (msLeft <= 0) {
25
- timeFragment = 'EXPIRED';
26
- isExpired = true;
27
- }
28
- else if (msLeft < MS_PER_DAY) {
16
+ if (msLeft <= 0)
17
+ return { fragment: 'EXPIRED', msLeft, expired: true };
18
+ if (msLeft < MS_PER_DAY) {
29
19
  const hours = Math.max(1, Math.floor(msLeft / MS_PER_HOUR));
30
- timeFragment = `${hours} hour${hours === 1 ? '' : 's'} left`;
20
+ return { fragment: `${hours} hour${hours === 1 ? '' : 's'} left`, msLeft, expired: false };
31
21
  }
32
- else {
33
- const days = Math.floor(msLeft / MS_PER_DAY);
34
- timeFragment = `${days} day${days === 1 ? '' : 's'} left`;
22
+ const days = Math.floor(msLeft / MS_PER_DAY);
23
+ return { fragment: `${days} day${days === 1 ? '' : 's'} left`, msLeft, expired: false };
24
+ }
25
+ function inputPct(profile) {
26
+ const cap = profile.trial_token_cap_input ?? 0;
27
+ return { cap, pct: cap > 0 ? Math.round((profile.usage_input_tokens / cap) * 100) : 0 };
28
+ }
29
+ /** Server-supplied text (email, plan name) on one line with no control characters. */
30
+ function serverText(s) {
31
+ return sanitizeControlChars(String(s)).replace(/\s*\r?\n\s*/g, ' ').trim();
32
+ }
33
+ export function formatLoginBanner(profile) {
34
+ const email = serverText(profile.email);
35
+ if (profile.trial_mode === null) {
36
+ return { line: `Signed in as ${email} (${serverText(profile.plan)})`, warningLine: null };
35
37
  }
38
+ const t = trialTime(profile, Date.now());
36
39
  if (profile.trial_mode === 'byok') {
37
40
  return {
38
- line: `Signed in as ${profile.email} — BYOK trial — ${timeFragment}`,
39
- warningLine: isExpired
40
- ? `⚠ Your trial has expired. Visit cspeach.dev/upgrade.`
41
- : (msLeft < MS_PER_DAY ? `⚠ Trial expires soon — ${timeFragment}.` : null),
41
+ line: `Signed in as ${email} — BYOK trial — ${t.fragment}`,
42
+ warningLine: t.expired
43
+ ? 'Your trial has expired. Visit cspeach.dev/upgrade.'
44
+ : (t.msLeft < MS_PER_DAY ? `Trial expires soon — ${t.fragment}.` : null),
42
45
  };
43
46
  }
44
47
  // managed-capped
45
- const usedInput = profile.usage_input_tokens;
46
- const capInput = profile.trial_token_cap_input ?? 0;
47
- const inputPct = capInput > 0 ? Math.round((usedInput / capInput) * 100) : 0;
48
- const tokenFragment = capInput > 0
49
- ? ` — ${formatTokens(usedInput)}/${formatTokens(capInput)} tokens (${inputPct}%)`
48
+ const { cap, pct } = inputPct(profile);
49
+ const tokenFragment = cap > 0
50
+ ? ` — ${formatTokens(profile.usage_input_tokens)}/${formatTokens(cap)} tokens (${pct}%)`
50
51
  : '';
51
- const line = `Signed in as ${profile.email} — trial (managed) — ${timeFragment}${tokenFragment}`;
52
+ const line = `Signed in as ${email} — trial (managed) — ${t.fragment}${tokenFragment}`;
52
53
  let warning = null;
53
- if (isExpired) {
54
- warning = `⚠ Your trial has expired. Visit cspeach.dev/upgrade.`;
55
- }
56
- else if (inputPct >= 80) {
57
- warning = `⚠ Trial token budget at ${inputPct}% — upgrade or switch to BYOK before hitting the cap.`;
58
- }
59
- else if (msLeft < MS_PER_DAY) {
60
- warning = `⚠ Trial expires in ${timeFragment} — upgrade at cspeach.dev/upgrade.`;
61
- }
54
+ if (t.expired)
55
+ warning = 'Your trial has expired. Visit cspeach.dev/upgrade.';
56
+ else if (pct >= 80)
57
+ warning = `Trial token budget at ${pct}% — upgrade or switch to BYOK before hitting the cap.`;
58
+ else if (t.msLeft < MS_PER_DAY)
59
+ warning = `Trial expires in ${t.fragment} — upgrade at cspeach.dev/upgrade.`;
62
60
  return { line, warningLine: warning };
63
61
  }
62
+ /** Panels brand-line fold-in (piece 2 §10): "signed in as <email> · <plan or trial facts>". */
63
+ export function signInFragment(profile, now = Date.now()) {
64
+ const who = `signed in as ${serverText(profile.email)}`;
65
+ if (profile.trial_mode === null)
66
+ return `${who} · ${serverText(profile.plan)}`;
67
+ const t = trialTime(profile, now);
68
+ const time = t.expired ? 'trial expired' : t.fragment;
69
+ if (profile.trial_mode === 'byok')
70
+ return `${who} · BYOK trial · ${time}`;
71
+ const { cap, pct } = inputPct(profile);
72
+ const tokens = cap > 0 ? ` · ${formatTokens(profile.usage_input_tokens)} of ${formatTokens(cap)} tokens (${pct}%)` : '';
73
+ return `${who} · trial · ${time}${tokens}`;
74
+ }
64
75
  // Always render with a `k` suffix so the banner column width stays stable.
65
76
  // Sub-1k values that aren't zero render as `<1k` rather than the raw number.
66
77
  function formatTokens(n) {
@@ -1,35 +1,85 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
+ import stringWidth from 'string-width';
4
5
  import { hhmm, rewindRowLabel, packageUnknown } from '../rewind/format.js';
6
+ import { roleProps, selectedProps } from './role-props.js';
7
+ import { getGlyphs } from '../renderer/glyphs.js';
8
+ import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
9
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
5
10
  /**
6
- * RewindPanel — UX Wave 3 / Task 3. The Esc-Esc (and /rewind) overlay that
7
- * turns "undo for your SAP system" into a picker. House style follows
8
- * SessionTimeline / TodoPanel / CommandPalette: a bordered box mounted above
9
- * the Footer by <App>, closes on Esc / q, every row wrap="truncate-end"
10
- * (Wave-1 rule — no dynamic-frame row may soft-wrap), and lists longer than
11
- * REWIND_VISIBLE_ROWS scroll in a window (CommandPalette pattern).
11
+ * RewindPanel — UX Wave 3 / Task 3; a card-slot occupant since CLI look
12
+ * piece 2 (§10). The Esc-Esc (and /rewind) card that turns "undo for your
13
+ * SAP system" into a picker. <App> mounts it in the card slot; it closes on
14
+ * Esc / q, every row is pre-cut to the card width (string-width aware) and
15
+ * also wrap="truncate-end", and lists taller than REWIND_VISIBLE_ROWS scroll
16
+ * in a window (CommandPalette pattern).
12
17
  *
13
- * Row: `▸ ZMNT_NOTE (TABL) · set_source · 14:32 · snapshot 12:01`
14
- * - restorable → bold, cyan when the cursor is on it.
15
- * - restorable delete with an unknown recreate package → a dim
16
- * `(package unknown — restore may fail)` tail (rider c).
18
+ * Row: `❯ Wrote ZMNT_NOTE (TABL) · 14:32 · snapshot 12:01` (local times)
19
+ * - restorable → bold; the selected row is SAP blue + bold.
20
+ * - restorable delete with an unknown recreate package → its own dim
21
+ * `package unknown — restore may fail` line (rider c).
17
22
  * - non-restorable → dim, with the candidate's reason.
18
23
  *
19
24
  * Enter on a restorable row swaps the footer hint for an inline confirm line
20
- * (`restore … to snapshot 12:01? runs the write gates — [y]/[n]`). `y` fires
25
+ * (`restore … to snapshot 12:01? runs write gates — [y]/[n]`). `y` fires
21
26
  * onRestore(candidate) and closes; `n` / Esc returns to the list. The panel
22
27
  * NEVER runs the write itself — onRestore bridges to the gated executor.
23
28
  *
24
- * Empty state: a single dim `no restorable writes this session` line that
29
+ * Empty state: a single dim `No restorable writes this session` line that
25
30
  * closes on any key.
31
+ *
32
+ * Height (spec §2 cap): border 2 + title 1 + margin 1 + ↑ more 1 +
33
+ * REWIND_VISIBLE_ROWS + ↓ more 1 + margin 1 + hint 1 = 17, plus the App's
34
+ * marginTop 1 = SLOT_ROWS_ESTIMATE.rewind (18). The window is measured in
35
+ * ROWS: a package-unknown candidate takes two.
26
36
  */
27
- export const REWIND_VISIBLE_ROWS = 10;
28
- export function RewindPanel({ candidates, onRestore, onClose }) {
37
+ export const REWIND_VISIBLE_ROWS = 9;
38
+ /** Border 1 + paddingX 2, on each side. */
39
+ const CHROME_COLS = 6;
40
+ const rowsOf = (c) => (packageUnknown(c) ? 2 : 1);
41
+ /** First index past the window that starts at `start` (at least one row). */
42
+ function windowEnd(candidates, start) {
43
+ let end = start;
44
+ let used = 0;
45
+ while (end < candidates.length && (end === start || used + rowsOf(candidates[end]) <= REWIND_VISIBLE_ROWS)) {
46
+ used += rowsOf(candidates[end]);
47
+ end++;
48
+ }
49
+ return end;
50
+ }
51
+ /** The scroll window for `cursor`: starts three above it where possible and
52
+ * always contains it. */
53
+ export function rewindWindow(candidates, cursor) {
54
+ let maxOffset = candidates.length;
55
+ while (maxOffset > 0 && windowEnd(candidates, maxOffset - 1) === candidates.length)
56
+ maxOffset--;
57
+ let start = Math.max(0, Math.min(cursor - 3, maxOffset));
58
+ let end = windowEnd(candidates, start);
59
+ while (cursor >= end && start < cursor) {
60
+ start++;
61
+ end = windowEnd(candidates, start);
62
+ }
63
+ return { start, end };
64
+ }
65
+ /** The inline confirm question. A long object name is cut first so the
66
+ * `[y]/[n]` keys stay on screen. */
67
+ function confirmLine(c, inner) {
68
+ const tail = ` to snapshot ${hhmm(c.snapshotAt)}? runs write gates — [y]/[n]`;
69
+ const head = 'restore ';
70
+ const objBudget = Math.max(1, inner - stringWidth(head) - stringWidth(tail));
71
+ return truncateToWidth(`${head}${truncateToWidth(sanitizeControlChars(c.object), objBudget)}${tail}`, inner);
72
+ }
73
+ export function RewindPanel({ candidates, onRestore, onClose, width = uiWidth() }) {
29
74
  const [cursor, setCursor] = useState(0);
30
75
  const [confirming, setConfirming] = useState(null);
31
76
  const empty = candidates.length === 0;
32
77
  useInput((input, key) => {
78
+ // Ctrl+C closes the panel outright, nothing written (fix round 3, item 4).
79
+ if (key.ctrl && input === 'c') {
80
+ onClose();
81
+ return;
82
+ }
33
83
  // Empty state: any key dismisses.
34
84
  if (empty) {
35
85
  onClose();
@@ -64,18 +114,20 @@ export function RewindPanel({ candidates, onRestore, onClose }) {
64
114
  return;
65
115
  }
66
116
  });
67
- const maxOffset = Math.max(0, candidates.length - REWIND_VISIBLE_ROWS);
68
- const windowStart = Math.max(0, Math.min(cursor - 3, maxOffset));
69
- const visible = candidates.slice(windowStart, windowStart + REWIND_VISIBLE_ROWS);
70
- const below = Math.max(0, candidates.length - (windowStart + REWIND_VISIBLE_ROWS));
71
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 72, children: [_jsx(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: "Rewind \u2014 undo a write this session \u00B7 times UTC" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [empty && _jsx(Text, { dimColor: true, children: "no restorable writes this session" }), !empty && windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), !empty && visible.map((c, i) => {
117
+ const inner = Math.max(1, width - CHROME_COLS);
118
+ const { start: windowStart, end: windowStop } = rewindWindow(candidates, cursor);
119
+ const visible = candidates.slice(windowStart, windowStop);
120
+ const below = Math.max(0, candidates.length - windowStop);
121
+ const scrolls = windowStart > 0 || below > 0;
122
+ const g = getGlyphs();
123
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 2, width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: truncateToWidth('Rewind · undo a write from this session', inner) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [empty && _jsx(Text, { ...roleProps('dim'), children: "No restorable writes this session" }), !empty && windowStart > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2191 ", windowStart, " more"] }), !empty && visible.map((c, i) => {
72
124
  const idx = windowStart + i;
73
125
  const sel = idx === cursor;
74
- const glyph = sel ? '▸ ' : ' ';
126
+ const row = truncateToWidth(`${sel ? `${g.cursor} ` : ' '}${rewindRowLabel(c)}`, inner);
75
127
  return (_jsxs(React.Fragment, { children: [c.restorable
76
- ? _jsxs(Text, { bold: true, color: sel ? 'cyan' : undefined, wrap: "truncate-end", children: [glyph, rewindRowLabel(c)] })
77
- : _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [glyph, rewindRowLabel(c)] }), packageUnknown(c) && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: " package unknown \u2014 restore may fail" }))] }, idx));
78
- }), !empty && below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] })] }), _jsx(Box, { marginTop: 1, children: confirming ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: ["restore ", confirming.object, " to snapshot ", hhmm(confirming.snapshotAt), "? runs write gates \u2014 [y]/[n]"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: empty
128
+ ? _jsx(Text, { ...(sel ? selectedProps() : { bold: true }), wrap: "truncate-end", children: row })
129
+ : _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: row }), packageUnknown(c) && (_jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: truncateToWidth(' package unknown — restore may fail', inner) }))] }, idx));
130
+ }), !empty && below > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2193 ", below, " more"] })] }), _jsx(Box, { marginTop: 1, children: confirming ? (_jsx(Text, { ...roleProps('warn'), wrap: "truncate-end", children: confirmLine(confirming, inner) })) : (_jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: empty
79
131
  ? 'any key to close'
80
- : `${candidates.length > REWIND_VISIBLE_ROWS ? '↑↓ scroll · ' : ''}Enter restore · Esc/q close` })) })] }));
132
+ : `${scrolls ? '↑↓ scroll · ' : ''}Enter restore · Esc/q close` })) })] }));
81
133
  }
@@ -0,0 +1,47 @@
1
+ // src/ui/role-props.ts
2
+ //
3
+ // Ink colour props from theme roles (CLI look piece 2, spec §1.2). Every
4
+ // migrated Ink component takes colour ONLY through this helper, so NO_COLOR
5
+ // reaches Ink whatever chalk instance Ink bundles. Under NO_COLOR it returns
6
+ // no colour props (bold survives); selection then shows through ❯ + bold.
7
+ import chalk from 'chalk';
8
+ import { PALETTE, CHIP_BG, CHIP_FG } from '../renderer/theme.js';
9
+ import { getBackground } from '../renderer/look.js';
10
+ import { isNoColor } from '../renderer/color-mode.js';
11
+ /** 16-colour names matching theme.ts BASIC (paint at chalk level 1). */
12
+ const BASIC_NAME = {
13
+ sap: 'blue', ok: 'green', warn: 'yellow', bad: 'red', frame: 'gray', dim: 'gray', brand: 'yellow',
14
+ };
15
+ export function roleProps(role, opts = {}) {
16
+ const bold = opts.bold ? { bold: true } : {};
17
+ if (colourOff())
18
+ return bold;
19
+ const color = chalk.level === 1 ? BASIC_NAME[role] : PALETTE[getBackground()][role];
20
+ return { color, ...bold };
21
+ }
22
+ /** The selected row: SAP blue + bold; NO_COLOR → bold only. */
23
+ export function selectedProps() {
24
+ return roleProps('sap', { bold: true });
25
+ }
26
+ function colourOff() {
27
+ return isNoColor() || chalk.level === 0;
28
+ }
29
+ /**
30
+ * A filled tag chip (e.g. APPROVAL) as Ink text + props, matching theme.chip():
31
+ * SAP-blue fill with white text; 16 colours → blue/white; NO_COLOR → `[TAG]` bold.
32
+ */
33
+ export function inkChip(label) {
34
+ if (colourOff())
35
+ return { text: `[${label}]`, props: { bold: true } };
36
+ if (chalk.level === 1)
37
+ return { text: ` ${label} `, props: { backgroundColor: 'blue', color: 'white', bold: true } };
38
+ return { text: ` ${label} `, props: { backgroundColor: CHIP_BG, color: CHIP_FG, bold: true } };
39
+ }
40
+ /**
41
+ * Legacy look only (inside a getLook() === 'legacy' branch): today's literal
42
+ * Ink colour, unchanged while colour is on, so the legacy bytes stay the same;
43
+ * none under NO_COLOR or chalk level 0.
44
+ */
45
+ export function legacyInkColor(name) {
46
+ return colourOff() ? undefined : name;
47
+ }
@@ -14,11 +14,12 @@ import React, { createContext, useContext } from 'react';
14
14
  const DEFAULT_STATE = {
15
15
  system: '', user: '', transport: null, locksHeld: [],
16
16
  tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 100, recentTurns: [],
17
- writeMode: null, lastWriteTransport: null,
17
+ writeMode: null, lastWriteTransport: null, confirmedTransport: null,
18
18
  // Credits default OFF: BYOK/local/ai-hub and any pre-resolution frame keep
19
19
  // the dollar rendering. Only an explicit managed+metered resolution flips it.
20
20
  creditsMode: false, creditsSessionTotal: 0, creditsBalance: null,
21
21
  creditsUnitLabel: 'credits', creditsPending: false,
22
+ standalone: false,
22
23
  };
23
24
  /**
24
25
  * Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
@@ -1,5 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
2
3
  import { Box, Text, useInput } from 'ink';
4
+ import stringWidth from 'string-width';
5
+ import { roleProps } from './role-props.js';
6
+ import { getGlyphs } from '../renderer/glyphs.js';
7
+ import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
8
+ import { toolLabel } from '../renderer/tool-labels.js';
9
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
10
+ import { hhmm } from '../rewind/format.js';
3
11
  // v1.1: add 'skipped' (approval rejection logs) and 'rolled_back' (snapshot restore).
4
12
  // Both need session-schema bump (schema_version 2) — deferred.
5
13
  const MUTATING_TOOLS = new Set([
@@ -22,11 +30,49 @@ export function toTimelineEntry(call) {
22
30
  isError: call.is_error,
23
31
  };
24
32
  }
25
- export function SessionTimeline({ entries, onClose }) {
33
+ /**
34
+ * Write rows shown at once. Height (spec §2 cap): border 2 + title 1 +
35
+ * margin 1 + ↑ more 1 + TIMELINE_VISIBLE_ROWS + ↓ more 1 = 15, plus the
36
+ * App's marginTop 1 = SLOT_ROWS_ESTIMATE.timeline (16). Longer histories
37
+ * scroll with ↑↓ and open on the newest writes.
38
+ */
39
+ export const TIMELINE_VISIBLE_ROWS = 9;
40
+ /** Border 1 + paddingX 2, on each side. */
41
+ const CHROME_COLS = 6;
42
+ export function SessionTimeline({ entries, onClose, width = uiWidth() }) {
26
43
  const writes = entries.filter((e) => MUTATING_TOOLS.has(e.toolName));
44
+ const maxOffset = Math.max(0, writes.length - TIMELINE_VISIBLE_ROWS);
45
+ const [offset, setOffset] = useState(maxOffset);
46
+ const start = Math.min(offset, maxOffset);
47
+ const visible = writes.slice(start, start + TIMELINE_VISIBLE_ROWS);
48
+ const below = writes.length - (start + visible.length);
49
+ const g = getGlyphs();
50
+ const inner = Math.max(1, width - CHROME_COLS);
27
51
  useInput((input, key) => {
28
- if (key.escape || input === 'q')
52
+ if (key.escape || input === 'q' || (key.ctrl && input === 'c')) {
29
53
  onClose();
54
+ return;
55
+ }
56
+ // Functional updaters: a burst of arrows before a re-render must not
57
+ // collapse into one step. Clamped to [0, maxOffset].
58
+ if (key.upArrow) {
59
+ setOffset((o) => Math.max(0, Math.min(o, maxOffset) - 1));
60
+ return;
61
+ }
62
+ if (key.downArrow) {
63
+ setOffset((o) => Math.min(maxOffset, Math.max(0, o) + 1));
64
+ return;
65
+ }
30
66
  });
31
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 72, children: [_jsx(Text, { bold: true, color: "cyan", children: "Write history \\u2014 Esc to close" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [writes.length === 0 && _jsx(Text, { dimColor: true, children: "(no writes this session)" }), writes.map((e, i) => (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [e.timestamp, " "] }), _jsx(Text, { color: e.isError ? 'red' : 'green', children: e.isError ? '\u25CF failed ' : '\u25CF applied' }), _jsxs(Text, { children: [" ", e.toolName.padEnd(20)] }), _jsxs(Text, { color: "yellow", children: [" ", e.objectName] }), _jsxs(Text, { dimColor: true, children: [" (", e.objectType, ") ", e.durationMs, "ms"] })] }, i)))] })] }));
67
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 2, width: width, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Write history ", _jsx(Text, { ...roleProps('dim'), children: maxOffset > 0 ? '· ↑↓ scroll · Esc to close' : '· Esc to close' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [writes.length === 0 && _jsx(Text, { ...roleProps('dim'), children: "No writes this session" }), start > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2191 ", start, " more"] }), visible.map((e, i) => {
68
+ const time = `${hhmm(e.timestamp)} `;
69
+ const glyph = e.isError ? g.failed : g.done;
70
+ const verb = toolLabel(e.toolName, {}).verb;
71
+ const type = e.objectType !== '-' ? ` (${sanitizeControlChars(e.objectType)})` : '';
72
+ const ms = ` · ${e.durationMs} ms`;
73
+ // Pre-cut the object name so the verb, type and duration stay visible.
74
+ const fixed = stringWidth(`${time}${glyph} ${verb} ${type}${ms}`);
75
+ const name = truncateToWidth(sanitizeControlChars(e.objectName), Math.max(1, inner - fixed));
76
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('dim'), children: time }), _jsx(Text, { ...roleProps(e.isError ? 'bad' : 'ok'), children: glyph }), _jsxs(Text, { children: [" ", verb, " ", name, type] }), _jsx(Text, { ...roleProps('dim'), children: ms })] }, start + i));
77
+ }), below > 0 && _jsxs(Text, { ...roleProps('dim'), children: [" \u2193 ", below, " more"] })] })] }));
32
78
  }