@cspeach/cli 0.9.0 → 1.1.0

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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -9,5 +9,19 @@ class AskQuestionEmitter extends EventEmitter {
9
9
  this.emit('request', { ...params, resolve });
10
10
  });
11
11
  }
12
+ /**
13
+ * UX Wave 2 / Task 2 — v2 batched form request. Same no-listener fallback
14
+ * contract as request(): resolve cancelled (v2 shape — empty answers) so
15
+ * callers without a mounted App fall through to their own path.
16
+ */
17
+ requestForm(form) {
18
+ return new Promise((resolve) => {
19
+ if (this.listenerCount('request') === 0) {
20
+ resolve({ formId: form.formId, answers: [], cancelled: true });
21
+ return;
22
+ }
23
+ this.emit('request', { ...form, resolve });
24
+ });
25
+ }
12
26
  }
13
27
  export const askQuestionEmitter = new AskQuestionEmitter();
package/dist/ui/body.js CHANGED
@@ -3,6 +3,7 @@ import React, { useReducer, useEffect } from 'react';
3
3
  import { Text, Static } from 'ink';
4
4
  import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
5
5
  import { WIDGET_CONTROL } from './widget-control.js';
6
+ import { formatNoticeLine } from '../renderer/notices.js';
6
7
  /**
7
8
  * Frame interval for batching streaming chunks before dispatching to the
8
9
  * reducer (W2.2 — 2026-05-18). Ink already throttles its stdout writes to
@@ -162,9 +163,21 @@ export const Body = React.memo(function Body({ chunkEmitter }) {
162
163
  // synchronously on unmount so end-of-stream output isn't dropped.
163
164
  const batcher = createChunkBatcher((buffered) => dispatch({ chunk: buffered }));
164
165
  const handler = (chunk) => batcher.push(chunk);
166
+ // B2 fix (2026-06-12) — consume the 'warn'/'info' notice channel.
167
+ // Tool handlers (transport-resolution Rule 9 notices, write-tool
168
+ // snapshot lines) emit these levels on the same emitter; before this
169
+ // they were silent no-ops in the live Ink REPL. Notices are formatted
170
+ // to a single line and pushed through the SAME batcher so their order
171
+ // relative to surrounding chunk text is preserved.
172
+ const warnHandler = (msg) => batcher.push(formatNoticeLine('warn', msg));
173
+ const infoHandler = (msg) => batcher.push(formatNoticeLine('info', msg));
165
174
  chunkEmitter.on('chunk', handler);
175
+ chunkEmitter.on('warn', warnHandler);
176
+ chunkEmitter.on('info', infoHandler);
166
177
  return () => {
167
178
  chunkEmitter.off('chunk', handler);
179
+ chunkEmitter.off('warn', warnHandler);
180
+ chunkEmitter.off('info', infoHandler);
168
181
  batcher.dispose();
169
182
  };
170
183
  }, [chunkEmitter]);
@@ -0,0 +1,108 @@
1
+ // cspeach-cli/src/ui/context-grid.tsx
2
+ //
3
+ // UX Wave 3 / Task 5 — render the /context composition grid to lines of text.
4
+ //
5
+ // One bar per segment, proportional to the exact billed prompt total (share of
6
+ // USED context), capped at 40 columns. Classic mode = the same lines minus
7
+ // color, so this is a single
8
+ // pure line-builder with a `color` flag rather than two renderers. The Ink
9
+ // dispatch path emits these lines through chunkEmitter; the classic path
10
+ // console.logs them — same seam as /cost and /export.
11
+ //
12
+ // Wave-1 invariant (binding): NO row may ever wrap. Every row is truncated end
13
+ // to the terminal width AS PLAIN TEXT FIRST, then color is applied to the
14
+ // already-truncated string — so colored lines are truncated identically to
15
+ // classic ones and an ANSI escape can never be split by the cut.
16
+ import chalk from 'chalk';
17
+ import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
18
+ import { buildContextReport } from '../session/context-report.js';
19
+ import { readCostLog } from '../cost/cost-log.js';
20
+ /** Bars never exceed this many columns (Wave-1 proportional-bar cap). */
21
+ const BAR_MAX = 40;
22
+ /** Segment labels are padded to this width so values line up. */
23
+ const LABEL_WIDTH = 22;
24
+ /**
25
+ * Values (`~130k`) are padded to this width so the bars that follow line up.
26
+ * `~` + up to 3 digits + `k` = 5; a couple of spare columns keep three-digit
27
+ * k-values aligned. The value sits BEFORE the bar (triage #14) so a narrow
28
+ * terminal truncates bar characters, never the number's digits.
29
+ */
30
+ const VALUE_WIDTH = 7;
31
+ /** k-format a token count: 130400 → "130k". */
32
+ function k(n) {
33
+ return `${Math.round(n / 1000)}k`;
34
+ }
35
+ /** Truncate a plain string to `width` columns (end-truncation, no wrap). */
36
+ function truncateEnd(s, width) {
37
+ if (width <= 0)
38
+ return '';
39
+ return s.length > width ? s.slice(0, width) : s;
40
+ }
41
+ /**
42
+ * Build the /context grid as an array of ready-to-emit lines.
43
+ *
44
+ * The header shows the one exact number (lastTurnInput, in k). Every segment
45
+ * row shows a `~` (approx) token figure — the heuristic honesty marker. Bars
46
+ * are scaled against lastTurnInput so they read as "share of the USED
47
+ * context", and capped at BAR_MAX.
48
+ */
49
+ export function renderContextGrid(report, opts = {}) {
50
+ const color = opts.color ?? true;
51
+ const width = opts.width ?? (typeof process !== 'undefined' && process.stdout?.columns ? process.stdout.columns : 80);
52
+ const unicode = shouldUseUnicodeBoxChars();
53
+ const fill = unicode ? '█' : '#';
54
+ const arrow = unicode ? '→' : '->';
55
+ const lines = [];
56
+ // ── header: context: 87k / 200k (43%) ── (the only exact number)
57
+ // Truncate the PLAIN text first, then color — so the width cut is identical
58
+ // in both modes and can never land inside an ANSI escape.
59
+ const pctInt = Math.round(report.pct * 100);
60
+ const headerText = truncateEnd(`context: ${k(report.lastTurnInput)} / ${k(report.windowTokens)} (${pctInt}%)`, width);
61
+ lines.push(color ? chalk.bold(headerText) : headerText);
62
+ lines.push('');
63
+ // Scale bars against the exact billed prompt total (lastTurnInput) so a
64
+ // segment's bar reads as its share of the USED context, capped at BAR_MAX.
65
+ // Fall back to the largest segment when there is no billed input yet (so an
66
+ // unbilled session still renders a readable grid).
67
+ const scaleBase = report.lastTurnInput > 0
68
+ ? report.lastTurnInput
69
+ : Math.max(1, ...report.segments.map((s) => s.approxTokens));
70
+ for (const seg of report.segments) {
71
+ const barLen = Math.min(BAR_MAX, Math.max(0, Math.round((BAR_MAX * seg.approxTokens) / scaleBase)));
72
+ const bar = fill.repeat(barLen);
73
+ const label = seg.label.padEnd(LABEL_WIDTH).slice(0, LABEL_WIDTH);
74
+ const value = `~${k(seg.approxTokens)}`;
75
+ // Value BEFORE the bar (triage #14): a narrow terminal truncates the bar
76
+ // run, never the number's digits (value-after-bar clipped "~130k" → "~1").
77
+ // Plain, color-free row: identical bytes in both modes → truncation can
78
+ // never split an ANSI escape, and classic == Ink minus color trivially.
79
+ const row = `${label} ${value.padEnd(VALUE_WIDTH)} ${bar}`;
80
+ lines.push(truncateEnd(row, width));
81
+ }
82
+ if (report.hints.length > 0) {
83
+ lines.push('');
84
+ for (const h of report.hints) {
85
+ // Same order as the header: plain-truncate, THEN color.
86
+ const text = truncateEnd(`${arrow} ${h}`, width);
87
+ lines.push(color ? chalk.dim(text) : text);
88
+ }
89
+ }
90
+ return lines;
91
+ }
92
+ /**
93
+ * `/context` command — read the session's cost log, build the composition
94
+ * report, render the grid, and emit it line by line through the caller's sink
95
+ * (chunkEmitter in Ink mode, console.log in classic mode — same seam as
96
+ * runCostCommand). Read-only, no network. `costEntries` is injectable for
97
+ * tests; otherwise it is read from the session's JSONL log.
98
+ */
99
+ export async function runContextCommand(opts) {
100
+ const { session, emit } = opts;
101
+ const costEntries = opts.costEntries ?? (await readCostLog(session.id));
102
+ const report = buildContextReport(session, costEntries);
103
+ const lines = renderContextGrid(report, { color: opts.color });
104
+ emit('');
105
+ for (const line of lines)
106
+ emit(line);
107
+ emit('');
108
+ }
package/dist/ui/footer.js CHANGED
@@ -1,44 +1,94 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useState, useEffect, useCallback } from 'react';
3
- import { Box, Text, useInput } from 'ink';
4
- import TextInput from 'ink-text-input';
2
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ // D29 paste fix (2026-06-12): vendored paste-safe replacement for
5
+ // ink-text-input — multi-chunk pastes were losing every chunk but the
6
+ // last (stale-prop closure in the upstream component). See text-input.tsx.
7
+ import TextInput from './text-input.js';
5
8
  import Fuse from 'fuse.js';
6
- import * as fs from 'node:fs';
7
- import * as os from 'node:os';
8
- import * as path from 'node:path';
9
- const HISTORY_FILE = path.join(os.homedir(), '.cspeach', 'history');
9
+ // Task 8 (ux-wave1) — persistence lives in the shared writer (one file,
10
+ // one dedup guard for the Footer, Ink handleSubmit, and classic readline).
11
+ // It resolves os.homedir() lazily so tests can point it at a temp dir.
12
+ import { appendHistoryEntry, readHistoryEntries, flattenHistoryEntry } from '../repl/history.js';
10
13
  function loadHistory() {
11
- try {
12
- return fs.readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean);
13
- }
14
- catch {
15
- return [];
16
- }
14
+ return readHistoryEntries();
17
15
  }
18
16
  function appendHistory(line, history) {
19
- // Skip consecutive duplicates (matches bash HISTCONTROL=ignoredups).
20
- if (history[history.length - 1] === line)
21
- return;
22
- fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
23
- fs.appendFileSync(HISTORY_FILE, line + '\n');
24
- history.push(line);
17
+ // The shared writer flattens (newlines/tabs/C0 controls → ' '), trims,
18
+ // and skips consecutive duplicates (bash HISTCONTROL=ignoredups / CC
19
+ // parity). Mirror into the in-memory array ONLY when the entry was
20
+ // actually written, so arrow-up and Ctrl+R stay consistent with the
21
+ // file. Same flatten as the writer (tab-flatten fast-follow) so the
22
+ // mirror never diverges from the persisted entry.
23
+ const entry = flattenHistoryEntry(line);
24
+ if (appendHistoryEntry(entry))
25
+ history.push(entry);
25
26
  }
26
27
  const PEACH = '#FFCBA4';
27
- export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled }) {
28
+ /** The input prompt — its width feeds the display budget below. */
29
+ const PROMPT = 'CSPeach › ';
30
+ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange }) {
28
31
  const [value, setValue] = useState('');
32
+ // Wave1 live-fix (Bug 1, statusline duplication) — the input row must
33
+ // NEVER soft-wrap in the terminal: Ink's log-update erases logical
34
+ // lines, not terminal rows, so one wrapped dynamic-frame row leaks the
35
+ // StatusLine into scrollback on every keystroke repaint (see the
36
+ // maxDisplayWidth doc in text-input.tsx for the ink source refs). Cap
37
+ // the displayed value at columns − prompt − 1 (the −1 keeps the row off
38
+ // the exact-width deferred-wrap edge on Windows Terminal). Re-rendered
39
+ // on stdout resize so a narrowed window tightens the budget promptly.
40
+ const { stdout } = useStdout();
41
+ const [, bumpOnResize] = useState(0);
42
+ useEffect(() => {
43
+ if (!stdout)
44
+ return;
45
+ const onResize = () => bumpOnResize((n) => n + 1);
46
+ stdout.on('resize', onResize);
47
+ return () => { stdout.off('resize', onResize); };
48
+ }, [stdout]);
49
+ const columns = stdout?.columns || 80;
50
+ const inputBudget = Math.max(8, columns - PROMPT.length - 1);
29
51
  const [history] = useState(loadHistory);
30
52
  const [histIdx, setHistIdx] = useState(-1);
31
53
  const [fuzzyMode, setFuzzyMode] = useState(false);
32
54
  const [fuzzyQuery, setFuzzyQuery] = useState('');
33
55
  const [fuzzyResults, setFuzzyResults] = useState([]);
56
+ // Task 7 (ux-wave1) — multiline continuation buffer. Holds COMPLETE
57
+ // lines (from trailing-`\` submits and multi-line pastes) until the
58
+ // final Enter joins them with the current input line into ONE message.
59
+ // The TextInput never sees this state — it stays single-line.
60
+ //
61
+ // bufferRef is the synchronous mirror of the same D29 shape as
62
+ // text-input's valueRef: a paste tail and its Enter can land in ONE
63
+ // stdin drain, so onPasteLines and handleSubmit may both run before
64
+ // React commits the state update. All reads/writes go through the ref;
65
+ // the state exists only to drive the indicator render.
66
+ const [buffer, setBuffer] = useState([]);
67
+ const bufferRef = useRef([]);
68
+ const updateBuffer = useCallback((lines) => {
69
+ bufferRef.current = lines;
70
+ setBuffer(lines);
71
+ }, []);
34
72
  const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
35
73
  // Mirror state into parent when it cares (e.g. App's ? key-binding gate).
36
74
  useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
75
+ // Task 7 — mirror the buffer depth into App (live-trigger suppression).
76
+ useEffect(() => { onBufferChange?.(buffer.length); }, [buffer.length, onBufferChange]);
77
+ // Task 7 — complete lines carved out of a multi-line paste by the
78
+ // TextInput (see onPasteLines in text-input.tsx). Append, never submit.
79
+ const handlePasteLines = useCallback((lines) => {
80
+ updateBuffer([...bufferRef.current, ...lines]);
81
+ }, [updateBuffer]);
37
82
  // If the parent pushes a prefill (palette pick, palette cancel restore,
38
83
  // empty-body guard), pre-fill our input. Keyed on the nonce so identical
39
- // text can re-fire.
84
+ // text can re-fire. Empty text is honoured too (Task 5): App's live
85
+ // key-triggers (`/`, `?`, Alt+M) push an empty prefill to SWALLOW the
86
+ // trigger character, which Ink also delivers to our TextInput — without
87
+ // the clear, the leaked char reappears once the overlay closes. Nonce 0
88
+ // is the untouched initial state (App never pushes nonce 0), so skip it
89
+ // rather than clearing on mount.
40
90
  useEffect(() => {
41
- if (prefill && prefill.text.length > 0)
91
+ if (prefill && prefill.nonce > 0)
42
92
  setValue(prefill.text);
43
93
  // eslint-disable-next-line react-hooks/exhaustive-deps
44
94
  }, [prefill?.nonce]);
@@ -70,6 +120,15 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
70
120
  }
71
121
  return;
72
122
  }
123
+ // Task 7 — Esc discards the multiline draft. Runs AFTER the fuzzy
124
+ // branch (fuzzy Esc closes the search) and only while this hook is
125
+ // active at all (isActive: !disabled) — when a palette/modal overlay
126
+ // is up the Footer is disabled, so overlay-Esc never races this.
127
+ if (key.escape && bufferRef.current.length > 0) {
128
+ updateBuffer([]);
129
+ onNotice?.('multiline draft discarded');
130
+ return;
131
+ }
73
132
  if (key.upArrow) {
74
133
  const next = Math.min(histIdx + 1, history.length - 1);
75
134
  setHistIdx(next);
@@ -82,8 +141,42 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
82
141
  setValue(next === -1 ? '' : history[history.length - 1 - next] ?? '');
83
142
  return;
84
143
  }
85
- });
144
+ // 2026-06-16 — go dormant while a modal (ask_question / coaching picker)
145
+ // owns input. Without this, arrow-key navigation INSIDE the modal also
146
+ // hits this handler (Ink delivers every keystroke to every active
147
+ // useInput), recalling a history line into the hidden Footer — so the
148
+ // last command "comes back" in the input once the modal closes. Mirrors
149
+ // why the TextInput is swapped for a placeholder when disabled.
150
+ }, { isActive: !disabled });
86
151
  const handleSubmit = useCallback((line) => {
152
+ // Task 7 — trailing `\` continues the message: strip the backslash,
153
+ // hold the line (indentation preserved — this is for ABAP snippets),
154
+ // clear the input, submit nothing. A bare `\` inserts a blank line.
155
+ if (line.endsWith('\\')) {
156
+ updateBuffer([...bufferRef.current, line.slice(0, -1)]);
157
+ setHistIdx(-1);
158
+ setValue('');
159
+ return;
160
+ }
161
+ // Task 7 — final submit with buffered lines: join into ONE message.
162
+ // The onSubmit contract is unchanged (one string, may contain \n) —
163
+ // App's beginSubmit / steering path takes it as a single message.
164
+ // The bare-`/` and `/history` intercepts are intentionally skipped
165
+ // here: mid-composition a `/` line is literal text (same pin as the
166
+ // live-trigger suppression in App).
167
+ if (bufferRef.current.length > 0) {
168
+ const joined = [...bufferRef.current, line].join('\n').trim();
169
+ updateBuffer([]);
170
+ setHistIdx(-1);
171
+ setValue('');
172
+ if (!joined)
173
+ return;
174
+ // The history file is one-entry-per-line; flatten so a multiline
175
+ // message doesn't split into N bogus entries on the next load.
176
+ appendHistory(joined.replace(/\n/g, ' '), history);
177
+ onSubmit(joined);
178
+ return;
179
+ }
87
180
  if (!line.trim())
88
181
  return;
89
182
  appendHistory(line.trim(), history);
@@ -98,9 +191,9 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
98
191
  return;
99
192
  }
100
193
  onSubmit(line.trim());
101
- }, [onSubmit, onSlashTrigger, onHistoryTrigger, history]);
194
+ }, [onSubmit, onSlashTrigger, onHistoryTrigger, history, updateBuffer]);
102
195
  if (fuzzyMode) {
103
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search..." }), fuzzyResults.map((r, i) => _jsx(Text, { dimColor: i > 0, children: r }, i)), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
196
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search...", maxDisplayWidth: Math.max(8, columns - 5) }), fuzzyResults.map((r, i) => (_jsx(Text, { dimColor: i > 0, wrap: "truncate-end", children: flattenHistoryEntry(r) }, i))), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
104
197
  }
105
198
  // 2026-05-17 — when a modal owns input (coaching picker / ask_question),
106
199
  // render a dim placeholder INSTEAD of the live TextInput. Otherwise
@@ -108,7 +201,7 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
108
201
  // Footer submission fires after the modal resolves → spurious picker
109
202
  // for what the user thought was their modal answer.
110
203
  if (disabled) {
111
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
204
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
112
205
  }
113
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })] }));
206
+ return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length, " line", buffer.length === 1 ? '' : 's', " pending (Enter sends, \\ continues)"] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, maxDisplayWidth: inputBudget })] })] }));
114
207
  }
package/dist/ui/header.js CHANGED
@@ -4,6 +4,13 @@ import { useSapState } from './sap-state-store.js';
4
4
  import { CLI_VERSION } from '../lib/version.js';
5
5
  const PEACH = '#FFCBA4';
6
6
  /**
7
+ * ⚠ SUPERSEDED (UX Wave 1, Task 4 — 2026-07) by ui/status-line.tsx, the
8
+ * bottom-anchored borderless statusline mounted in App between TurnStatus
9
+ * and Footer. This component is no longer rendered anywhere: Phase D6
10
+ * removed the persistent top Header (inline mode can't pin the frame top),
11
+ * and the statusline took over the SapStateStore display role. Kept for
12
+ * reference, not deleted.
13
+ *
7
14
  * Single-line rich header (Phase D3, 2026-05-16) — back to one line per
8
15
  * Laeeq's feedback that the two-line variant felt worse. Uses tighter
9
16
  * separators and shorter labels so it fits cleanly on typical terminals
@@ -19,8 +19,8 @@
19
19
  // pick, so a line flows slash → at → empty-body guard → submit no
20
20
  // matter where it entered the pipeline.
21
21
  import { isExactSlashMatch, filterSlashEntries } from '../repl/slash-picker.js';
22
- import { pendingAtTokenPrefix, filterFileChoices } from '../repl/at-picker.js';
23
- import { SKILL_CATALOG } from '../skill-catalog.js';
22
+ import { pendingAtTokenPrefix } from '../repl/at-picker.js';
23
+ import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
24
24
  export function nextResolutionStep(line, files) {
25
25
  const trimmed = line.trim();
26
26
  if (trimmed.startsWith('/') && !isExactSlashMatch(trimmed)) {
@@ -38,19 +38,36 @@ export function nextResolutionStep(line, files) {
38
38
  const filenames = files.map((f) => f.filename);
39
39
  const atPrefix = pendingAtTokenPrefix(trimmed, filenames);
40
40
  if (atPrefix !== null && files.length > 0) {
41
- const seeded = filterFileChoices(atPrefix, files);
42
- if (seeded.length === 1) {
43
- return emptyBodyGuard(trimmed.replace(/@\S*$/, `@${seeded[0].filename}`));
44
- }
41
+ // Always open the picker when the line ends in an unresolved @-token —
42
+ // even when it narrows to a single file. The old single-match shortcut
43
+ // silently auto-resolved AND auto-submitted, so the user never saw which
44
+ // file was attached and couldn't keep typing (e.g. add `--scope`). Showing
45
+ // the palette + inserting-on-pick (App handles that) is the correct flow.
45
46
  return { kind: 'open-at', prefix: atPrefix, line: trimmed };
46
47
  }
47
48
  // Empty workspace: nothing to pick — let the downstream @-resolution
48
49
  // produce its normal "no file matching" message.
49
50
  return emptyBodyGuard(trimmed);
50
51
  }
51
- /** Substitute the picked command head into the pending line and continue. */
52
- export function applySlashPick(pickedName, rest, files) {
52
+ /**
53
+ * Substitute the picked command head into the pending line and continue.
54
+ *
55
+ * `fillIn` (UX Wave 1, Task 5 — CC v2.1.19x behavior): true when the palette
56
+ * was opened by the LIVE bare-`/` trigger (original line empty). A pick then
57
+ * PREFILLS `/{command} ` into the Footer — never submits, not even for
58
+ * builtins that take no body — so the user can add arguments before Enter.
59
+ * The typed-and-submitted flow (fillIn=false, the default) keeps today's
60
+ * semantics: re-run the pipeline, which submits when the line is complete.
61
+ */
62
+ export function applySlashPick(pickedName, rest, files, fillIn = false) {
53
63
  const name = pickedName.startsWith('/') ? pickedName : `/${pickedName}`;
64
+ if (fillIn) {
65
+ return {
66
+ kind: 'prefill',
67
+ text: `${name} `,
68
+ hint: `↪ ${name} — add arguments or press Enter to run`,
69
+ };
70
+ }
54
71
  return nextResolutionStep(`${name}${rest}`, files);
55
72
  }
56
73
  /** Substitute the picked filename into the rightmost @-token and continue. */
@@ -68,6 +85,16 @@ function emptyBodyGuard(line) {
68
85
  const splitIdx = line.search(/\s/);
69
86
  const head = splitIdx === -1 ? line : line.slice(0, splitIdx);
70
87
  const body = splitIdx === -1 ? '' : line.slice(splitIdx).trim();
88
+ // Retired-but-redirected name with no body: prefill the NEW name so the
89
+ // user both learns the rename and doesn't dispatch an empty prompt.
90
+ const aliasTarget = SKILL_ALIASES[head.toLowerCase().slice(1)];
91
+ if (aliasTarget !== undefined && body.length === 0) {
92
+ return {
93
+ kind: 'prefill',
94
+ text: `/${aliasTarget} `,
95
+ hint: `↪ ${head.toLowerCase()} is now /${aliasTarget} — type your prompt and press Enter`,
96
+ };
97
+ }
71
98
  const isSkill = SKILL_CATALOG.some((s) => `/${s.name}` === head.toLowerCase());
72
99
  if (isSkill && body.length === 0) {
73
100
  return {
@@ -0,0 +1,10 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /**
3
+ * Open-request channel for the Rewind panel (UX Wave 3 / Task 3). The Esc-Esc
4
+ * chord opens the panel from inside <App>'s own useInput; the `/rewind` builtin
5
+ * lives in repl.tsx (a separate React-less dispatch), so it signals the mounted
6
+ * <App> through this singleton emitter — the same producer→consumer pattern as
7
+ * todoEmitter / approvalEmitter. In classic mode <App> is never mounted, this
8
+ * emitter has no listeners, and `/rewind` takes the inquirer path instead.
9
+ */
10
+ export const rewindEmitter = new EventEmitter();
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { hhmm, rewindRowLabel, packageUnknown } from '../rewind/format.js';
5
+ /**
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).
12
+ *
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).
17
+ * - non-restorable → dim, with the candidate's reason.
18
+ *
19
+ * 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
21
+ * onRestore(candidate) and closes; `n` / Esc returns to the list. The panel
22
+ * NEVER runs the write itself — onRestore bridges to the gated executor.
23
+ *
24
+ * Empty state: a single dim `no restorable writes this session` line that
25
+ * closes on any key.
26
+ */
27
+ export const REWIND_VISIBLE_ROWS = 10;
28
+ export function RewindPanel({ candidates, onRestore, onClose }) {
29
+ const [cursor, setCursor] = useState(0);
30
+ const [confirming, setConfirming] = useState(null);
31
+ const empty = candidates.length === 0;
32
+ useInput((input, key) => {
33
+ // Empty state: any key dismisses.
34
+ if (empty) {
35
+ onClose();
36
+ return;
37
+ }
38
+ if (confirming) {
39
+ if (input === 'y' || input === 'Y') {
40
+ onRestore(confirming);
41
+ onClose();
42
+ return;
43
+ }
44
+ // n / N / Esc / q — back to the list, nothing written.
45
+ setConfirming(null);
46
+ return;
47
+ }
48
+ if (key.escape || input === 'q') {
49
+ onClose();
50
+ return;
51
+ }
52
+ if (key.upArrow) {
53
+ setCursor((cu) => Math.max(0, cu - 1));
54
+ return;
55
+ }
56
+ if (key.downArrow) {
57
+ setCursor((cu) => Math.min(candidates.length - 1, cu + 1));
58
+ return;
59
+ }
60
+ if (key.return) {
61
+ const c = candidates[cursor];
62
+ if (c && c.restorable)
63
+ setConfirming(c); // no-op on a dimmed non-restorable row
64
+ return;
65
+ }
66
+ });
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) => {
72
+ const idx = windowStart + i;
73
+ const sel = idx === cursor;
74
+ const glyph = sel ? '▸ ' : ' ';
75
+ 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
79
+ ? 'any key to close'
80
+ : `${candidates.length > REWIND_VISIBLE_ROWS ? '↑↓ scroll · ' : ''}Enter restore · Esc/q close` })) })] }));
81
+ }
@@ -14,6 +14,7 @@ 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,
17
18
  };
18
19
  /**
19
20
  * Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
@@ -6,6 +6,7 @@ const MUTATING_TOOLS = new Set([
6
6
  'sap_set_source', 'sap_update_method', 'sap_delete_object', 'sap_activate',
7
7
  'sap_create_object', 'sap_transport_release', 'sap_message_maintain',
8
8
  'sap_number_range_intervals', 'sap_service_binding_publish',
9
+ 'sap_service_binding_unpublish',
9
10
  ]);
10
11
  export function toTimelineEntry(call) {
11
12
  // args shape depends on the tool; extract defensively.
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ import { useSapState } from './sap-state-store.js';
4
+ /**
5
+ * Persistent SAP statusline (UX Wave 1, Task 4) — Header reborn,
6
+ * bottom-anchored. Single borderless line rendered between <TurnStatus />
7
+ * and <Footer /> in the dynamic Ink frame:
8
+ *
9
+ * S4H │ S.LAEEQ │ tr K903417 │ approval-gated │ $0.54
10
+ *
11
+ * Descends from ui/header.tsx (Phase D3) minus the border (3 rows → 1) and
12
+ * the version/🍑 (the startup banner owns those). All fields come from the
13
+ * SapStateStore singleton, so transport creation, spend accrual, and
14
+ * write-mode cycling (Shift+Tab / Alt+M / /mode) re-render it live.
15
+ *
16
+ * The mode chip is omitted until the store's writeMode is seeded at REPL
17
+ * startup (null = pre-seed; only reachable in tests and the first ms of
18
+ * mount). Chip colors: advisory-only yellow / approval-gated green / auto
19
+ * cyan.
20
+ */
21
+ const MODE_COLORS = {
22
+ 'advisory-only': 'yellow',
23
+ 'approval-gated': 'green',
24
+ auto: 'cyan',
25
+ };
26
+ export function StatusLine() {
27
+ const s = useSapState();
28
+ const spendDisplay = `$${s.dollarSpendToday.toFixed(2)}`;
29
+ // Wave1 live-fix (Bug 1) — one TRUNCATING <Text> instead of a <Box> of
30
+ // sibling Texts. The statusline lives in the dynamic Ink frame, where a
31
+ // row that soft-wraps in the terminal breaks log-update's logical-line
32
+ // erase accounting and leaks stale rows into scrollback (the live
33
+ // "statusline printed 11×" bug — see text-input.tsx maxDisplayWidth doc
34
+ // for the ink source refs). Nested <Text> children squash into one text
35
+ // node, so wrap="truncate-end" clips the whole row at the terminal
36
+ // width via ANSI-aware cli-truncate — this row can never wrap, whatever
37
+ // the field lengths.
38
+ return (_jsxs(Text, { wrap: "truncate-end", children: [s.system
39
+ ? _jsx(Text, { color: "cyan", children: s.system })
40
+ : _jsx(Text, { dimColor: true, children: "no system" }), _jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { dimColor: true, children: " \u2502 tr " }), s.transport
41
+ ? _jsx(Text, { color: "yellow", children: s.transport })
42
+ : _jsx(Text, { dimColor: true, children: "none" }), s.writeMode && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: MODE_COLORS[s.writeMode], children: s.writeMode })] })), _jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay })] }));
43
+ }