@cspeach/cli 1.1.33 → 1.1.35

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 (119) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +182 -39
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -59,6 +59,9 @@ export function applyBlockerAnswers(env, answers, answerer, nowIso) {
59
59
  lastEditedBy: answerer,
60
60
  };
61
61
  }
62
+ function plural(n, word) {
63
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
64
+ }
62
65
  function affirmative(reply) {
63
66
  const v = reply.trim().toLowerCase();
64
67
  return v === 'y' || v === 'yes';
@@ -83,7 +86,9 @@ export async function offerAnswerBlockers(args) {
83
86
  if (blockers.length === 0)
84
87
  return null;
85
88
  const reply = await askYesNo({
86
- question: `Answer the ${blockers.length} unanswered blocker(s) now?`,
89
+ question: blockers.length === 1
90
+ ? 'Answer the open blocker now?'
91
+ : `Answer the ${blockers.length} open blockers now?`,
87
92
  confirm: args.confirm,
88
93
  prompt: args.prompt,
89
94
  });
@@ -92,16 +97,15 @@ export async function offerAnswerBlockers(args) {
92
97
  const answers = new Map();
93
98
  for (let i = 0; i < blockers.length; i++) {
94
99
  const b = blockers[i];
95
- // 2026-09-17 — the "Blocker i/n: …" line is printed only on the free-text
96
- // path below. When a picker handles the question it shows the same text
97
- // itself, and printing it here too showed every blocker twice.
98
- // Choices first, where the question has them and a picker is available.
100
+ // The "Blocker i of n: …" line is printed only on the free-text fallback
101
+ // below. The picker shows the question itself and leaves its own trace.
102
+ // The picker first, for every blocker (options or free text).
99
103
  // 'write' falls through to the text box; 'skip' leaves the blocker open.
100
104
  let askText = true;
101
- if (b.options && b.options.length > 0 && args.choose) {
105
+ if (args.choose) {
102
106
  const c = await args.choose({
103
107
  question: b.text,
104
- options: b.options,
108
+ options: b.options ?? [],
105
109
  multiSelect: !!b.multiSelect,
106
110
  index: i,
107
111
  total: blockers.length,
@@ -114,19 +118,26 @@ export async function offerAnswerBlockers(args) {
114
118
  else if (c.kind === 'skip') {
115
119
  askText = false;
116
120
  }
121
+ else if (c.kind === 'stop') {
122
+ // Ctrl+C (Task 4, m2): ask none of the rest. Answers already given
123
+ // are kept and saved below; the others stay open.
124
+ break;
125
+ }
117
126
  // c.kind === 'write' → askText stays true
118
127
  }
119
128
  if (askText) {
120
129
  args.log('');
121
- args.log(`Blocker ${i + 1}/${blockers.length}: ${b.text}`);
122
- const a = (await args.prompt(' Your answer (Enter to skip): ')).trim();
130
+ args.log(`Blocker ${i + 1} of ${blockers.length}: ${b.text}`);
131
+ const a = (await args.prompt('Your answer (Enter to skip): ')).trim();
123
132
  if (a.length > 0)
124
133
  answers.set(b.id, a);
125
134
  }
126
135
  }
127
136
  if (answers.size === 0) {
128
137
  args.log('');
129
- args.log('No answers entered — all blockers left open for the viewer.');
138
+ args.log(blockers.length === 1
139
+ ? 'No answer entered. The blocker stays open. Answer it later at cspeach.dev/edit.'
140
+ : `No answers entered. The ${blockers.length} blockers stay open. Answer them later at cspeach.dev/edit.`);
130
141
  return null;
131
142
  }
132
143
  const nowIso = (args.now ?? (() => new Date().toISOString()))();
@@ -139,7 +150,9 @@ export async function offerAnswerBlockers(args) {
139
150
  writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
140
151
  const stillOpen = blockers.length - answers.size;
141
152
  args.log('');
142
- args.log(`Recorded ${answers.size} answer(s) in the file`
143
- + (stillOpen > 0 ? ` — ${stillOpen} blocker(s) still open for the viewer.` : '.'));
153
+ args.log(`Recorded ${plural(answers.size, 'answer')} in the file.`
154
+ + (stillOpen === 0 ? ''
155
+ : stillOpen === 1 ? ' 1 blocker is still open. Answer it later at cspeach.dev/edit.'
156
+ : ` ${stillOpen} blockers are still open. Answer them later at cspeach.dev/edit.`));
144
157
  return answers.size;
145
158
  }
@@ -6,10 +6,11 @@
6
6
  import { diffLines } from 'diff';
7
7
  import { paint } from './theme.js';
8
8
  import { getBoxChars } from '../ui/ascii-chars.js';
9
+ import { ERROR_DETAIL_ROWS, SNAPSHOT_DETAIL_ROWS } from './verify-chain.js';
9
10
  import { ANSI_RE } from './tool-labels.js';
11
+ import { wrapRows } from './wrap-rows.js';
10
12
  export const MIN_CARD_WIDTH = 40;
11
13
  const DIFF_LINES = 6;
12
- const DETAIL_MAX = 60;
13
14
  /** Myers diff is O(N·D) and synchronous: past this budget we show no diff
14
15
  * at all (object + footer only), never a partial one. */
15
16
  const DIFF_TIMEOUT_MS = 200;
@@ -33,7 +34,7 @@ function footerParts(input) {
33
34
  else if (input.snapshot === 'new_object')
34
35
  parts.push(paint('dim', '– snapshot (new object)'));
35
36
  else if (input.snapshot === 'source_read_failed')
36
- parts.push(paint('dim', `– snapshot (no source read${input.snapshotDetail ? ': ' + cut(flat(clean(input.snapshotDetail)), 40) : ''})`));
37
+ parts.push(paint('dim', '– snapshot (no source read)'));
37
38
  else
38
39
  parts.push(paint('dim', '– snapshot (skipped)'));
39
40
  parts.push(input.written ? `${paint('ok', '✓')} written` : `${paint('bad', '✗')} written`);
@@ -54,15 +55,35 @@ function footerParts(input) {
54
55
  else if (a.state === 'unverified')
55
56
  parts.push(paint('warn', '· activation unverified'));
56
57
  else
57
- parts.push(`${paint('bad', '✗')} activation${a.detail ? ' ' + paint('bad', `(${cut(flat(clean(a.detail)), DETAIL_MAX)})`) : ''}`);
58
+ parts.push(`${paint('bad', '✗')} activation`);
58
59
  }
59
60
  else {
60
61
  // Activation is blocked — say so; a pending step would lie.
61
- parts.push(`${paint('bad', '✗')} syntax ${paint('bad', `(${cut(flat(clean(input.syntax.errors[0] ?? 'syntax errors')), DETAIL_MAX)})`)}`);
62
+ parts.push(`${paint('bad', '✗')} syntax`);
62
63
  parts.push(paint('dim', '· activation blocked'));
63
64
  }
64
65
  return parts;
65
66
  }
67
+ /**
68
+ * Fix batch 2026-10-12 (Task 2): the reasons behind a failed step — the
69
+ * snapshot read failure, the first syntax error, SAP's activation refusal —
70
+ * are what the user needs, so they are rows of their own (wrapped at word
71
+ * breaks to `cells`, up to the verify-chain caps), never cut on the footer.
72
+ */
73
+ function detailLines(input, cells) {
74
+ const out = [];
75
+ const wrap = (text, rows) => wrapRows(flat(clean(text)), Math.max(1, cells), rows);
76
+ if (input.snapshot === 'source_read_failed' && input.snapshotDetail) {
77
+ out.push(...wrap(`No source read: ${input.snapshotDetail}`, SNAPSHOT_DETAIL_ROWS).map((r) => paint('dim', r)));
78
+ }
79
+ if (input.syntax !== 'not_checked' && !input.syntax.ok) {
80
+ out.push(...wrap(input.syntax.errors[0] ?? 'syntax errors', ERROR_DETAIL_ROWS).map((r) => paint('bad', r)));
81
+ }
82
+ else if (input.syntax !== 'not_checked' && input.activation?.state === 'failed' && input.activation.detail) {
83
+ out.push(...wrap(input.activation.detail, ERROR_DETAIL_ROWS).map((r) => paint('bad', r)));
84
+ }
85
+ return out;
86
+ }
66
87
  /** Changed lines for the card. Exported for tests only. */
67
88
  export function diffBody(card, differ = defaultDiffer) {
68
89
  if (card.oldSource === undefined || card.newSource === undefined)
@@ -99,6 +120,7 @@ export function buildChangeCard(input, width, box = getBoxChars()) {
99
120
  // cut to the width so nothing wraps (colour dropped only on a cut part).
100
121
  for (const p of parts)
101
122
  out.push(visibleLen(p) > width ? cut(p.replace(ANSI, ''), width) : p);
123
+ out.push(...detailLines(input, width));
102
124
  if (card.transport)
103
125
  out.push(paint('dim', cut(`· ${clean(card.transport)}`, width)));
104
126
  return out.join('\n');
@@ -117,6 +139,8 @@ export function buildChangeCard(input, width, box = getBoxChars()) {
117
139
  lines.push(row(paint(l.startsWith('+') ? 'ok' : 'bad', cut(l, inner))));
118
140
  if (body.hidden > 0)
119
141
  lines.push(row(paint('dim', ` … +${body.hidden} lines`)));
142
+ for (const d of detailLines(input, inner))
143
+ lines.push(row(d));
120
144
  lines.push(...footerLines(parts, chain, transportText, width, b, row));
121
145
  return lines.join('\n');
122
146
  }
@@ -8,7 +8,8 @@ import { paint } from './theme.js';
8
8
  import { truncateToWidth } from './ui-width.js';
9
9
  import { sanitizeControlChars } from './sanitize.js';
10
10
  import { formatCost } from '../cost/pricing.js';
11
- import { formatCredits, CREDITS_PENDING, creditsActive } from '../cost/credits.js';
11
+ import { formatCredits, CREDITS_PENDING, creditsActive, tokensOnlyActive } from '../cost/credits.js';
12
+ import { AZURE_INVOICE_LINE } from '../cost/display-mode.js';
12
13
  export const SHORTCUTS_HINT = '? shortcuts';
13
14
  const SEP = ' · ';
14
15
  /**
@@ -17,6 +18,9 @@ const SEP = ' · ';
17
18
  * total, BYOK/local see the session dollars — never the other.
18
19
  */
19
20
  export function formatSpendSegment(s) {
21
+ // Azure (Task 5b): the customer's own Azure bill — no price of ours to show.
22
+ if (tokensOnlyActive())
23
+ return AZURE_INVOICE_LINE;
20
24
  if (!s.creditsMode)
21
25
  return `$${s.dollarSpendToday.toFixed(2)}`;
22
26
  if (s.creditsPending && s.creditsSessionTotal === 0)
@@ -78,6 +82,8 @@ export function formatFooterLine(s, width) {
78
82
  * cost otherwise, '' when there is none.
79
83
  */
80
84
  export function turnSpendText(p) {
85
+ if (tokensOnlyActive())
86
+ return '';
81
87
  if (creditsActive()) {
82
88
  const c = p.credits;
83
89
  // 2026-10-02: the whole user request (every model round), not its last round.
@@ -36,13 +36,18 @@ import stringWidth from 'string-width';
36
36
  import { getLook } from './look.js';
37
37
  import { getGlyphs } from './glyphs.js';
38
38
  import { truncateToWidth, uiWidth } from './ui-width.js';
39
+ import { wrapRows } from './wrap-rows.js';
40
+ import { paint } from './theme.js';
41
+ import chalk from 'chalk';
39
42
  import { getNoticeLogPath, recordFullNotice } from './notice-log.js';
40
43
  import { getEffectiveBodyWidth, isDeclaredOneShot } from './tty.js';
41
44
  import { EventEmitter } from 'node:events';
45
+ /** Fix batch 2026-10-12 — a panels notice wraps onto at most this many rows. */
46
+ export const NOTICE_MAX_ROWS = 3;
42
47
  /**
43
- * Format one notice as a single terminal line (trailing newline included).
44
- * Multi-line messages are collapsed to one line — notices are glanceable
45
- * margin notes, not documents; anything long belongs in the tool result.
48
+ * Format one notice (trailing newline included). Multi-line messages are
49
+ * collapsed to one line of text; in the panels look that text then wraps
50
+ * onto up to NOTICE_MAX_ROWS screen rows (fix batch 2026-10-12).
46
51
  *
47
52
  * Piece 2 §9: warn → the attention shape ("! message"), info → the info
48
53
  * shape ("· message", dim). Same glyphs as every other notice.
@@ -50,10 +55,11 @@ import { EventEmitter } from 'node:events';
50
55
  export function formatNoticeLine(level, message) {
51
56
  // Live fix 2026-09-26: a SAP HTML error page must never leak tags/CSS here.
52
57
  const readable = looksLikeMarkup(message) ? cleanErrorText(message, 400) : message;
53
- let oneLine = readable.replace(/\s*\r?\n\s*/g, ' ').trim();
54
- // Live fix 2026-09-26 (panels): never wrap mid-word — cut to one screen
55
- // line ("<glyph> " + text ≤ uiWidth) and keep the full text in the
56
- // session's notice log. Legacy stays byte-identical.
58
+ const oneLine = readable.replace(/\s*\r?\n\s*/g, ' ').trim();
59
+ // Live fix 2026-09-26 (panels): never let the terminal wrap mid-word —
60
+ // the text is pre-wrapped at word breaks inside uiWidth (below) and a cut
61
+ // notice keeps its full text in the session's notice log. Legacy stays
62
+ // byte-identical.
57
63
  // Review I3: only when a screen is there to wrap (stdout is a TTY) or the
58
64
  // full text has somewhere to go (a notice log). One-shot / piped output
59
65
  // keeps the whole single line — nothing else would record it.
@@ -61,15 +67,23 @@ export function formatNoticeLine(level, message) {
61
67
  // keeps the whole line too.
62
68
  const wrapsOnScreen = process.stdout.isTTY === true && !isDeclaredOneShot();
63
69
  if (getLook() === 'panels' && (wrapsOnScreen || getNoticeLogPath() !== undefined)) {
70
+ // Fix batch 2026-10-12 (Task 2): a notice may carry what the user must
71
+ // act on (a transport number, SAP's reason), so it wraps at word breaks
72
+ // onto up to NOTICE_MAX_ROWS rows — further rows indented two spaces, the
73
+ // notice shape — instead of being cut to one. Only a longer notice is
74
+ // cut; its full text goes to the notice log and the screen says so.
64
75
  const g = getGlyphs();
65
76
  const room = Math.min(uiWidth(), getEffectiveBodyWidth()) - stringWidth(level === 'warn' ? g.attention : g.info) - 1;
66
- const cut = truncateToWidth(oneLine, room);
67
- // Review M10: truncateToWidth turns tabs into two spaces — compare against
68
- // the same normalisation so only a real cut is logged.
69
- if (cut !== oneLine.replace(/\t/g, ' ')) {
77
+ const rows = wrapRows(oneLine, room, NOTICE_MAX_ROWS);
78
+ const cut = wrapRows(oneLine, room, NOTICE_MAX_ROWS + 1).length > NOTICE_MAX_ROWS;
79
+ const logPath = getNoticeLogPath();
80
+ if (cut)
70
81
  recordFullNotice(level, message);
71
- oneLine = cut;
72
- }
82
+ const head = level === 'warn' ? formatAttention({ text: rows[0] }) : formatInfo(rows[0]);
83
+ const more = rows.slice(1).map((r) => (level === 'warn' ? ` ${chalk.bold(r)}` : paint('dim', ` ${r}`)));
84
+ if (cut && logPath)
85
+ more.push(paint('dim', truncateToWidth(` Full text: ${logPath}`, room + 2)));
86
+ return head + more.map((r) => `${r}\n`).join('');
73
87
  }
74
88
  return level === 'warn' ? formatAttention({ text: oneLine }) : formatInfo(oneLine);
75
89
  }
@@ -165,7 +165,9 @@ const MESSAGES = {
165
165
  'Walking enhancement spots…',
166
166
  ],
167
167
  sap_message_maintain: [
168
- '(stub — short-circuit)',
168
+ 'Reading the message class…',
169
+ 'Writing the messages…',
170
+ 'Reading the messages back…',
169
171
  ],
170
172
  sap_number_range_intervals: [
171
173
  '(stub — short-circuit)',
@@ -1,5 +1,6 @@
1
1
  import { formatCost } from '../cost/pricing.js';
2
- import { creditsActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
2
+ import { creditsActive, tokensOnlyActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
3
+ import { AZURE_INVOICE_LINE } from '../cost/display-mode.js';
3
4
  import { formatTurnReceipt, turnSpendText } from './footer-line.js';
4
5
  import { formatAttention } from './notice-shape.js';
5
6
  import { paint } from './theme.js';
@@ -79,12 +80,18 @@ export function formatStatusFooter(params) {
79
80
  const tokStr = inCredits ? '' : tokens.toLocaleString() + ' tok this turn';
80
81
  const cachedStr = !inCredits && cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
81
82
  const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
82
- const costTurnStr = inCredits
83
- ? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
84
- : (costThisTurn != null ? formatCost(costThisTurn) : '');
85
- const costSessStr = inCredits
83
+ // Azure (Task 5b): tokens only, the price line says where the bill is.
84
+ const tokensOnly = tokensOnlyActive();
85
+ const costTurnStr = tokensOnly
86
86
  ? ''
87
- : (costSession != null ? `session: ${formatCost(costSession)}` : '');
87
+ : inCredits
88
+ ? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
89
+ : (costThisTurn != null ? formatCost(costThisTurn) : '');
90
+ const costSessStr = tokensOnly
91
+ ? AZURE_INVOICE_LINE
92
+ : inCredits
93
+ ? ''
94
+ : (costSession != null ? `session: ${formatCost(costSession)}` : '');
88
95
  // Classic full receipt (piece 2 restyle — same facts, reviewed pin):
89
96
  // rules at uiWidth() in the frame role, ' · ' separators, the large-session
90
97
  // advisory as a `!` notice (largeSessionNotice).
@@ -177,7 +177,13 @@ const RULES = {
177
177
  sap_unlock: sapObject('Unlocked'),
178
178
  sap_service_binding_publish: sapObject('Published'),
179
179
  sap_service_binding_unpublish: sapObject('Unpublished'),
180
- sap_message_maintain: (a) => ({ verb: 'Message', object: shortRight([str(a['messageClass']), str(a['messageNumber'])].filter(Boolean).join(' ')) }),
180
+ sap_message_maintain: (a) => {
181
+ const n = Array.isArray(a['messages']) ? a['messages'].length : 0;
182
+ if (n > 0) {
183
+ return { verb: 'Messages', object: shortRight(str(a['messageClass']) ?? ''), detail: n === 1 ? '1 message' : `${n} messages` };
184
+ }
185
+ return { verb: 'Messages', object: shortRight([str(a['messageClass']), str(a['messageNumber'])].filter(Boolean).join(' ')) };
186
+ },
181
187
  sap_number_range_intervals: (a) => ({ verb: 'Number range', object: shortRight(str(a['nrObject']) ?? '') }),
182
188
  ask_question: (a) => ({ verb: 'Question', object: shortRight(str(a['question']) ?? '') }),
183
189
  todo_set: () => ({ verb: 'Updated todos', object: '' }),
@@ -257,7 +263,7 @@ const PRESENT = {
257
263
  'Created transport': 'Creating transport', 'Transport for': 'Finding transport for',
258
264
  'Listed transports': 'Listing transports', Released: 'Releasing', 'Created domain': 'Creating domain',
259
265
  'Created data element': 'Creating data element', Locked: 'Locking', Unlocked: 'Unlocking',
260
- Published: 'Publishing', Unpublished: 'Unpublishing', Message: 'Maintaining message',
266
+ Published: 'Publishing', Unpublished: 'Unpublishing', Messages: 'Maintaining messages in',
261
267
  'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
262
268
  'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
263
269
  'Searched web': 'Searching web', 'Read project context': 'Reading project context',
@@ -8,13 +8,15 @@
8
8
  * never synthesized. States render truthfully:
9
9
  * - snapshot 'new_object' → "① snapshot – (new object)" (genuine ADT 404 —
10
10
  * nothing to save; NEVER used for other read failures)
11
- * - snapshot 'source_read_failed' → "① snapshot – (no source read: <msg>)" —
11
+ * - snapshot 'source_read_failed' → "① snapshot – (no source read)" and the
12
+ * reason on its own row under the chain —
12
13
  * the pre-write read failed for a non-404 reason; the object may exist
13
14
  * but there is no recovery copy. Hard-gate tools block instead of
14
15
  * emitting this; it only appears on soft (best-effort) gates.
15
16
  * - snapshot 'best_effort_failed' → "① snapshot – (skipped)" (write tools
16
17
  * where the gate is soft, e.g. class includes)
17
- * - syntax fail → "③ syntax ✗ (L12: MESSAGE …)" AND no ④ at all —
18
+ * - syntax fail → "③ syntax ✗", the first error whole on the rows under
19
+ * the chain (fix batch 2026-10-12: no 60-character cut) AND no ④ at all —
18
20
  * activation is blocked, so we don't show a fake pending step
19
21
  * - syntax 'not_checked' → "③ syntax – (checked at activation)" —
20
22
  * sap_set_class_include has no standalone syntax gate
@@ -28,21 +30,43 @@
28
30
  * result row in both renderers.
29
31
  */
30
32
  import chalk from 'chalk';
33
+ import { wrapRows } from './wrap-rows.js';
34
+ import { uiWidth } from './ui-width.js';
31
35
  const pass = (s) => `${s} ${chalk.green('✓')}`;
32
36
  const fail = (s, detail) => `${s} ${chalk.red('✗')}${detail ? ' ' + chalk.red(`(${detail})`) : ''}`;
33
37
  const skip = (s, why) => chalk.dim(`${s} – (${why})`);
34
- const ERROR_DETAIL_MAX = 60;
35
- const SNAPSHOT_DETAIL_MAX = 40;
36
- const truncate = (s, max) => {
37
- const flat = s.replace(/\s+/g, ' ').trim();
38
- return flat.length > max ? flat.substring(0, max - 1) + '…' : flat;
39
- };
38
+ /**
39
+ * Fix batch 2026-10-12 (Task 2): the SAP error is what the user needs, so it
40
+ * is never cut to a few characters. Details go on their own rows under the
41
+ * chain, wrapped at word breaks inside the width, up to these many rows (the
42
+ * last ends with … only when even that is not enough; the model gets the
43
+ * full text in the tool result).
44
+ */
45
+ export const ERROR_DETAIL_ROWS = 4;
46
+ export const SNAPSHOT_DETAIL_ROWS = 2;
47
+ /** Detail rows sit under the chain: its two-space indent plus two. */
48
+ const DETAIL_INDENT = ' ';
49
+ const flatDetail = (s) => s.replace(/\s+/g, ' ').trim();
50
+ /** Detail rows: indented, wrapped inside `width`, at most `maxRows`. */
51
+ export function detailRows(text, width, maxRows) {
52
+ return wrapRows(flatDetail(text), Math.max(1, width - DETAIL_INDENT.length), maxRows).map((r) => `${DETAIL_INDENT}${r}`);
53
+ }
54
+ /**
55
+ * Detail-row width: the UI width less the Ink body's 4-cell border overhead
56
+ * (tty.ts BORDER_OVERHEAD), so a row never reaches the body edge in either
57
+ * renderer. Pure (no tty import): many tool tests mock tty.js partially.
58
+ */
59
+ export function chainWidth() {
60
+ return Math.max(20, uiWidth() - 4);
61
+ }
40
62
  /**
41
63
  * Build the chain line (no trailing newline). Returns the full line
42
- * including the two-space indent that aligns with the ⎿ result row.
64
+ * including the two-space indent that aligns with the ⎿ result row, then
65
+ * any detail rows (snapshot read failure, first syntax error) under it.
43
66
  */
44
- export function buildWriteChainLine(input) {
67
+ export function buildWriteChainLine(input, width = chainWidth()) {
45
68
  const parts = [];
69
+ const details = [];
46
70
  // ① snapshot
47
71
  if (input.snapshot === 'taken')
48
72
  parts.push(pass('① snapshot'));
@@ -51,10 +75,10 @@ export function buildWriteChainLine(input) {
51
75
  else if (input.snapshot === 'source_read_failed') {
52
76
  // Honest: the read failed — we do NOT know the object is new, and there
53
77
  // is no recovery copy. Never render this as "(new object)".
54
- const why = input.snapshotDetail
55
- ? `no source read: ${truncate(input.snapshotDetail, SNAPSHOT_DETAIL_MAX)}`
56
- : 'no source read';
57
- parts.push(skip('① snapshot', why));
78
+ parts.push(skip('① snapshot', 'no source read'));
79
+ if (input.snapshotDetail) {
80
+ details.push(...detailRows(`No source read: ${input.snapshotDetail}`, width, SNAPSHOT_DETAIL_ROWS).map((r) => chalk.dim(r)));
81
+ }
58
82
  }
59
83
  else
60
84
  parts.push(skip('① snapshot', 'skipped'));
@@ -70,10 +94,9 @@ export function buildWriteChainLine(input) {
70
94
  parts.push(chalk.dim('④ activate pending'));
71
95
  }
72
96
  else {
73
- const first = (input.syntax.errors[0] ?? 'syntax errors').replace(/\s+/g, ' ').trim();
74
- const detail = first.length > ERROR_DETAIL_MAX ? first.substring(0, ERROR_DETAIL_MAX - 1) + '…' : first;
75
- parts.push(fail('③ syntax', detail));
97
+ parts.push(fail('③ syntax'));
98
+ details.push(...detailRows(input.syntax.errors[0] ?? 'syntax errors', width, ERROR_DETAIL_ROWS).map((r) => chalk.red(r)));
76
99
  // No ④ — activation is blocked; rendering a pending step would lie.
77
100
  }
78
- return ' ' + parts.join(' ');
101
+ return [' ' + parts.join(' '), ...details].join('\n');
79
102
  }
@@ -0,0 +1,87 @@
1
+ // src/renderer/wrap-rows.ts
2
+ //
3
+ // The one wrap helper for card content (fix batch 2026-10-12, Task 2).
4
+ //
5
+ // THE RULE: card content wraps; chrome may cut. Text a person reads to make
6
+ // a decision — card titles, facts, option labels and descriptions, risk and
7
+ // failure reasons, SAP error details — is pre-wrapped here into a KNOWN
8
+ // number of rows. Ink never soft-wraps a live-frame row (its erase
9
+ // accounting counts rows), so callers draw each returned row as its own
10
+ // truncate-end <Text> and count every row in their height estimate. Status
11
+ // rows, hints, palettes, spinners, traces and "… N more" counters stay one
12
+ // row and cut with truncateToWidth.
13
+ //
14
+ // Replaces factRows (confirm-card) and wrapCardText (ask-card), which did the
15
+ // same job twice.
16
+ import stringWidth from 'string-width';
17
+ import { truncateToWidth } from './ui-width.js';
18
+ import { sanitizeControlChars } from './sanitize.js';
19
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
20
+ /** The longest grapheme-whole prefix of `word` that fits `cells` (at least one grapheme). */
21
+ function takeCells(word, cells) {
22
+ let out = '';
23
+ let used = 0;
24
+ for (const { segment } of GRAPHEMES.segment(word)) {
25
+ const w = stringWidth(segment);
26
+ if (used + w > cells && out.length > 0)
27
+ break;
28
+ out += segment;
29
+ used += w;
30
+ if (used >= cells)
31
+ break;
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * `text` as the rows it is drawn on, each at most `cells` terminal cells.
37
+ * Control characters are neutralised, tabs become two spaces and newlines
38
+ * one space. Text that fits is one row, unchanged. Otherwise it wraps at
39
+ * spaces (a word wider than the row is cut where it must be; wide characters
40
+ * count two cells and are never split) onto at most `maxRows` rows; when
41
+ * more is left, the last row ends with the `more` glyph (… / ...). With
42
+ * `maxRows` 1 this is truncateToWidth: one row, cut.
43
+ */
44
+ export function wrapRows(text, cells, maxRows = 1) {
45
+ const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
46
+ const width = Math.max(1, cells);
47
+ const max = Math.max(1, Math.floor(maxRows));
48
+ if (stringWidth(flat) <= width)
49
+ return [flat];
50
+ if (max === 1)
51
+ return [truncateToWidth(flat, width)];
52
+ const indent = /^ */.exec(flat)[0].slice(0, Math.max(0, width - 1));
53
+ const rows = [];
54
+ let cur = indent;
55
+ const curEmpty = () => cur.length === 0 || cur === indent;
56
+ for (let word of flat.trim().split(/ +/).filter((w) => w.length > 0)) {
57
+ while (stringWidth(word) > width) {
58
+ if (!curEmpty()) {
59
+ rows.push(cur);
60
+ cur = '';
61
+ }
62
+ const head = takeCells(word, width - stringWidth(cur));
63
+ rows.push(cur + head);
64
+ cur = '';
65
+ word = word.slice(head.length);
66
+ }
67
+ if (word.length === 0)
68
+ continue;
69
+ if (curEmpty())
70
+ cur += word;
71
+ else if (stringWidth(cur) + 1 + stringWidth(word) <= width)
72
+ cur += ` ${word}`;
73
+ else {
74
+ rows.push(cur);
75
+ cur = word;
76
+ }
77
+ }
78
+ if (!curEmpty() || rows.length === 0)
79
+ rows.push(cur);
80
+ if (rows.length <= max)
81
+ return rows;
82
+ const kept = rows.slice(0, max);
83
+ // The trailing space guarantees a cut (and so the more glyph) even when the
84
+ // joined rest would happen to fit.
85
+ kept[max - 1] = truncateToWidth(`${rows.slice(max - 1).join(' ')} `, width);
86
+ return kept;
87
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Azure tenant mode, Task 6b fix round 1 — a yes/no question at REPL start
3
+ * (the endpoint confirmation, "Sign in to Microsoft now?"). Ctrl+C there makes
4
+ * inquirer reject with ExitPromptError; that is the person's "no", never an
5
+ * unhandled error that ends the CLI with a stack. Any other error still throws.
6
+ */
7
+ export function askOrNo(ask) {
8
+ return async (q) => {
9
+ try {
10
+ return await ask(q);
11
+ }
12
+ catch (err) {
13
+ if (err?.name === 'ExitPromptError')
14
+ return false;
15
+ throw err;
16
+ }
17
+ };
18
+ }
@@ -8,22 +8,30 @@
8
8
  * read of the process-wide cost display mode (cost/credits.ts); no
9
9
  * inquirer, so the router can import it.
10
10
  */
11
- import { creditsActive } from '../cost/credits.js';
11
+ import { creditsActive, tokensOnlyActive } from '../cost/credits.js';
12
12
  // Pure generated data (type-only import inside), so this file stays light.
13
13
  import { BUNDLED_RUNS } from '../runs/bundled-runs.js';
14
14
  export const COST_DESCRIPTION_USD = 'Show this session\'s spend so far ($ and tokens)';
15
15
  export const COST_DESCRIPTION_CREDITS = 'Show the credits this session has used so far';
16
+ /** Azure tenant mode (Task 7, ruling 5): tokens only — never "$" or credits. */
17
+ export const COST_DESCRIPTION_TOKENS = "Show this session's tokens so far";
16
18
  /** Piece 2 §10: managed customers see credits, never "$" (cost/display-mode.ts). */
17
19
  /** /usage (2026-10-09): this month in credits — see commands/usage.ts. */
18
20
  export const USAGE_DESCRIPTION = 'Show this month’s credits, your balance and the top skills';
21
+ export const USAGE_DESCRIPTION_TOKENS = "Show where your usage is: your company's Azure invoice";
19
22
  export function costCommandDescription() {
23
+ if (tokensOnlyActive())
24
+ return COST_DESCRIPTION_TOKENS;
20
25
  return creditsActive() ? COST_DESCRIPTION_CREDITS : COST_DESCRIPTION_USD;
21
26
  }
27
+ export function usageCommandDescription() {
28
+ return tokensOnlyActive() ? USAGE_DESCRIPTION_TOKENS : USAGE_DESCRIPTION;
29
+ }
22
30
  export const BUILTIN_ENTRIES = [
23
31
  // Piece 2: /cancel is not listed — it exists only in the classic loop
24
32
  // (repl.tsx), and Esc / Ctrl+C already cancel under Ink.
25
33
  { name: '/cost', get description() { return costCommandDescription(); } },
26
- { name: '/usage', description: USAGE_DESCRIPTION },
34
+ { name: '/usage', get description() { return usageCommandDescription(); } },
27
35
  { name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
28
36
  { name: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
29
37
  { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
@@ -21,7 +21,7 @@ import { computeCost } from '../cost/pricing.js';
21
21
  import { computeSessionCost } from '../cost/session-cost.js';
22
22
  import { globalStore } from '../ui/sap-state-store.js';
23
23
  import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
24
- import { globalCredits } from '../cost/credits.js';
24
+ import { globalCredits, tokensOnlyActive } from '../cost/credits.js';
25
25
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
26
26
  import { shouldUseInk } from '../renderer/tty.js';
27
27
  import { isUserPrompt } from '../session/user-prompt.js';
@@ -49,7 +49,8 @@ export function computeTurnFacts(session, snapshot, turnModel) {
49
49
  const userTurns = session.messages.filter(isUserPrompt).length;
50
50
  const lifetimeAfter = session.usage.input_tokens + session.usage.output_tokens;
51
51
  const turnTokens = Math.max(0, lifetimeAfter - (snapshot.input + snapshot.output));
52
- const turnCost = computeCost(turnModel ?? session.model, {
52
+ // Azure: no price for the customer's deployment — never consult one.
53
+ const turnCost = tokensOnlyActive() ? 0 : computeCost(turnModel ?? session.model, {
53
54
  input: session.usage.input_tokens - snapshot.input,
54
55
  output: session.usage.output_tokens - snapshot.output,
55
56
  cacheRead: session.usage.cache_read_input_tokens - snapshot.cacheRead,