@cspeach/cli 1.1.45 → 1.1.47

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 (46) hide show
  1. package/dist/agent/chat-prompt.js +47 -0
  2. package/dist/agent/chat-unavailable.js +9 -0
  3. package/dist/agent/level-state.js +11 -0
  4. package/dist/agent/level.js +60 -0
  5. package/dist/agent/loop.js +110 -2
  6. package/dist/agent/provider-shape.js +6 -2
  7. package/dist/agent/providers/ai-hub-provider.js +3 -1
  8. package/dist/agent/providers/azure-provider.js +3 -1
  9. package/dist/agent/providers/byok-provider.js +3 -1
  10. package/dist/agent/providers/local-provider.js +3 -1
  11. package/dist/agent/style-state.js +11 -0
  12. package/dist/agent/style.js +59 -0
  13. package/dist/agent/system-prompt.js +24 -3
  14. package/dist/agent/tool-dispatch.js +55 -0
  15. package/dist/cli.js +8 -0
  16. package/dist/commands/config-set.js +59 -1
  17. package/dist/commands/config-show.js +1 -0
  18. package/dist/commands/export-docx.js +66 -0
  19. package/dist/commands/help.js +28 -2
  20. package/dist/config/loader.js +51 -1
  21. package/dist/docx/doc-meta.js +47 -0
  22. package/dist/docx/markdown-to-docx.js +250 -0
  23. package/dist/docx/write-docx.js +54 -0
  24. package/dist/one-shot.js +10 -0
  25. package/dist/projects/document-save.js +62 -0
  26. package/dist/projects/document-skills.js +9 -0
  27. package/dist/renderer/footer-line.js +6 -1
  28. package/dist/renderer/goodbye.js +4 -1
  29. package/dist/renderer/startup-lines.js +6 -2
  30. package/dist/renderer/tool-labels.js +2 -1
  31. package/dist/renderer/tool-widget.js +5 -1
  32. package/dist/repl/builtin-commands.js +4 -1
  33. package/dist/repl/chat-command.js +64 -0
  34. package/dist/repl/chat-lane.js +135 -0
  35. package/dist/repl/level-command.js +79 -0
  36. package/dist/repl/style-command.js +67 -0
  37. package/dist/repl.js +186 -105
  38. package/dist/session/queued-step.js +5 -1
  39. package/dist/session/schema.js +1 -0
  40. package/dist/session/store.js +4 -0
  41. package/dist/tools/hand-off.js +86 -0
  42. package/dist/tools/tool-loading.js +3 -0
  43. package/dist/ui/sap-state-store.js +1 -0
  44. package/dist/ui/widgets/ask-card.js +2 -1
  45. package/package.json +2 -2
  46. package/.cspeach/project.json +0 -4
@@ -0,0 +1,250 @@
1
+ // cspeach-cli/src/docx/markdown-to-docx.ts
2
+ //
3
+ // Markdown string + metadata → .docx Buffer. One pure function, no UI, no
4
+ // filesystem: the save hook, the export command and (later) the transport
5
+ // dossier all call it.
6
+ //
7
+ // Parser: the `marked` lexer the CLI already ships (renderer/markdown.ts uses
8
+ // it for terminal output). A fresh `Marked` instance here so the terminal
9
+ // extensions never leak in. Writer: the `docx` package.
10
+ //
11
+ // Rule: unknown Markdown degrades to plain text; this function never throws
12
+ // for any input string. Every block is converted inside a try/catch that falls
13
+ // back to the token's raw text.
14
+ import { Marked } from 'marked';
15
+ import { AlignmentType, BorderStyle, Document, ExternalHyperlink, HeadingLevel, LevelFormat, Packer, PageBreak, Paragraph, ShadingType, Table, TableCell, TableRow, TextRun, WidthType, } from 'docx';
16
+ const CODE_FONT = 'Consolas';
17
+ const CODE_SIZE = 18; // half-points: 9 pt
18
+ const CODE_FILL = 'F2F2F2';
19
+ const NUMBERED = 'cspeach-numbered';
20
+ // Text width of the docx default page (A4, 1-inch margins) in twips. Tables get
21
+ // explicit column widths that add up to it: with percentage widths only, docx
22
+ // writes 100-twip grid columns and Google Docs shows one letter per line.
23
+ const TEXT_WIDTH = 11906 - 2 * 1440;
24
+ const MIN_COL = 720; // half an inch
25
+ const HEADINGS = [
26
+ HeadingLevel.HEADING_1, HeadingLevel.HEADING_2, HeadingLevel.HEADING_3,
27
+ HeadingLevel.HEADING_4, HeadingLevel.HEADING_5, HeadingLevel.HEADING_6,
28
+ ];
29
+ /** One numbered-list instance per list so each restarts at 1. */
30
+ class Ctx {
31
+ numberedInstances = 0;
32
+ }
33
+ export async function markdownToDocx(markdown, meta) {
34
+ const ctx = new Ctx();
35
+ const body = blocksFrom(markdown, ctx);
36
+ const doc = new Document({
37
+ creator: 'CSPeach',
38
+ lastModifiedBy: 'CSPeach',
39
+ title: meta.title,
40
+ numbering: {
41
+ config: [{
42
+ reference: NUMBERED,
43
+ levels: [0, 1, 2].map((level) => ({
44
+ level,
45
+ format: LevelFormat.DECIMAL,
46
+ text: `%${level + 1}.`,
47
+ alignment: AlignmentType.START,
48
+ style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
49
+ })),
50
+ }],
51
+ },
52
+ styles: {
53
+ default: { document: { run: { font: 'Calibri', size: 22 } } },
54
+ },
55
+ sections: [{ children: [...titlePage(meta), ...body] }],
56
+ });
57
+ return Packer.toBuffer(doc);
58
+ }
59
+ /** Title, date, system, object, "Prepared with CSPeach", then a page break. (Task 3 pins it.) */
60
+ function titlePage(meta) {
61
+ const facts = [`Date: ${meta.date}`];
62
+ if (meta.system)
63
+ facts.push(`System: ${meta.system}`);
64
+ if (meta.object)
65
+ facts.push(`Object: ${meta.object}`);
66
+ return [
67
+ new Paragraph({ text: meta.title, heading: HeadingLevel.TITLE, spacing: { before: 2400 } }),
68
+ ...facts.map((f) => new Paragraph({ text: f })),
69
+ new Paragraph({ text: 'Prepared with CSPeach', spacing: { before: 480 } }),
70
+ new Paragraph({ children: [new PageBreak()] }),
71
+ ];
72
+ }
73
+ /** Lex, then convert every top-level block. Never throws. */
74
+ export function blocksFrom(markdown, ctx = new Ctx()) {
75
+ let tokens;
76
+ try {
77
+ tokens = new Marked({ gfm: true }).lexer(markdown ?? '');
78
+ }
79
+ catch {
80
+ return [new Paragraph({ text: markdown ?? '' })];
81
+ }
82
+ const out = [];
83
+ for (const t of tokens)
84
+ out.push(...blockToDocx(t, ctx, 0));
85
+ return out;
86
+ }
87
+ function blockToDocx(t, ctx, depth, extra = {}) {
88
+ try {
89
+ switch (t.type) {
90
+ case 'space': return [];
91
+ case 'heading': {
92
+ const h = t;
93
+ return [new Paragraph({ ...extra, heading: HEADINGS[Math.min(h.depth, 6) - 1], children: inline(h.tokens) })];
94
+ }
95
+ case 'paragraph':
96
+ case 'text': {
97
+ const p = t;
98
+ const kids = 'tokens' in p && p.tokens ? inline(p.tokens) : [new TextRun(p.text ?? p.raw ?? '')];
99
+ return [new Paragraph({ ...extra, children: kids })];
100
+ }
101
+ case 'list': return list(t, ctx, depth);
102
+ case 'code': return code(t);
103
+ case 'table': return [table(t)];
104
+ case 'blockquote': return quote(t, ctx, depth);
105
+ case 'hr': return [new Paragraph({ thematicBreak: true })];
106
+ case 'html': return [new Paragraph({ ...extra, text: t.raw.trim() })];
107
+ default: return [new Paragraph({ ...extra, text: ('raw' in t ? String(t.raw) : '').trim() })];
108
+ }
109
+ }
110
+ catch {
111
+ return [new Paragraph({ text: ('raw' in t ? String(t.raw) : '').trim() })];
112
+ }
113
+ }
114
+ /** Nested lists: a bullet or numbered paragraph per item; child lists recurse one level deeper. */
115
+ function list(l, ctx, depth) {
116
+ const level = Math.min(depth, 2);
117
+ const instance = l.ordered ? ctx.numberedInstances++ : -1;
118
+ const out = [];
119
+ for (const item of l.items) {
120
+ const mark = l.ordered
121
+ ? { numbering: { reference: NUMBERED, level, instance } }
122
+ : { bullet: { level } };
123
+ let first = true;
124
+ for (const child of item.tokens) {
125
+ if (child.type === 'list') {
126
+ out.push(...list(child, ctx, depth + 1));
127
+ continue;
128
+ }
129
+ // The first text block of an item carries the marker; later paragraphs are indented plain text.
130
+ out.push(...blockToDocx(child, ctx, depth, first ? mark : { indent: { left: 720 * (level + 1) } }));
131
+ first = false;
132
+ }
133
+ if (first)
134
+ out.push(new Paragraph({ ...mark, text: item.text ?? '' }));
135
+ }
136
+ return out;
137
+ }
138
+ function code(c) {
139
+ const lines = (c.text ?? '').split('\n');
140
+ return lines.map((line) => new Paragraph({
141
+ shading: { type: ShadingType.CLEAR, fill: CODE_FILL },
142
+ spacing: { before: 0, after: 0 },
143
+ children: [new TextRun({ text: line.length ? line : ' ', font: CODE_FONT, size: CODE_SIZE })],
144
+ }));
145
+ }
146
+ function table(t) {
147
+ const width = Math.max(1, t.header.length);
148
+ const pad = (cells) => {
149
+ const row = cells.slice(0, width);
150
+ while (row.length < width)
151
+ row.push({ text: '', tokens: [] });
152
+ return row;
153
+ };
154
+ const header = pad(t.header);
155
+ const rows = t.rows.map(pad);
156
+ const cols = columnWidths([header, ...rows], width);
157
+ const cell = (c, i, bold) => new TableCell({
158
+ width: { size: cols[i], type: WidthType.DXA },
159
+ children: [new Paragraph({ children: inline(c.tokens ?? [], bold ? { bold: true } : {}) })],
160
+ shading: bold ? { type: ShadingType.CLEAR, fill: 'E7E6E6' } : undefined,
161
+ });
162
+ return new Table({
163
+ width: { size: TEXT_WIDTH, type: WidthType.DXA },
164
+ columnWidths: cols,
165
+ rows: [
166
+ new TableRow({ tableHeader: true, children: header.map((c, i) => cell(c, i, true)) }),
167
+ ...rows.map((r) => new TableRow({ children: r.map((c, i) => cell(c, i, false)) })),
168
+ ],
169
+ });
170
+ }
171
+ /** Split the page width by each column's longest text (capped), never below MIN_COL. */
172
+ function columnWidths(rows, n) {
173
+ const weight = Array.from({ length: n }, (_, i) => Math.min(60, Math.max(3, ...rows.map((r) => (r[i]?.text ?? '').length))));
174
+ const total = weight.reduce((a, b) => a + b, 0);
175
+ const spare = Math.max(0, TEXT_WIDTH - MIN_COL * n);
176
+ const cols = weight.map((w) => MIN_COL + Math.floor((spare * w) / total));
177
+ cols[n - 1] += TEXT_WIDTH - cols.reduce((a, b) => a + b, 0);
178
+ return cols;
179
+ }
180
+ function quote(q, ctx, depth) {
181
+ const extra = {
182
+ indent: { left: 720 },
183
+ border: { left: { style: BorderStyle.SINGLE, size: 12, color: 'BFBFBF', space: 8 } },
184
+ };
185
+ const out = [];
186
+ for (const child of q.tokens)
187
+ out.push(...blockToDocx(child, ctx, depth, extra));
188
+ return out;
189
+ }
190
+ /** Inline tokens → runs. Unknown inline → its raw text. */
191
+ function inline(tokens, style = {}) {
192
+ const out = [];
193
+ for (const t of tokens ?? []) {
194
+ try {
195
+ switch (t.type) {
196
+ case 'text':
197
+ case 'escape':
198
+ out.push(run(t.text, style));
199
+ break;
200
+ case 'strong':
201
+ out.push(...inline(t.tokens, { ...style, bold: true }));
202
+ break;
203
+ case 'em':
204
+ out.push(...inline(t.tokens, { ...style, italics: true }));
205
+ break;
206
+ case 'del':
207
+ out.push(...inline(t.tokens, { ...style, strike: true }));
208
+ break;
209
+ case 'codespan':
210
+ out.push(run(t.text, { ...style, code: true }));
211
+ break;
212
+ case 'br':
213
+ out.push(new TextRun({ break: 1 }));
214
+ break;
215
+ case 'link': {
216
+ const l = t;
217
+ out.push(new ExternalHyperlink({
218
+ link: l.href,
219
+ children: [new TextRun({ text: unescapeHtml(textOf(l.tokens) || l.href), style: 'Hyperlink' })],
220
+ }));
221
+ break;
222
+ }
223
+ case 'image':
224
+ out.push(run(t.text || t.href, style));
225
+ break;
226
+ default: out.push(run('raw' in t ? String(t.raw) : '', style));
227
+ }
228
+ }
229
+ catch {
230
+ out.push(run('raw' in t ? String(t.raw) : '', style));
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+ // marked's lexer HTML-escapes text and inline code; docx escapes again on write,
236
+ // so without this the reader sees "&gt;" instead of ">".
237
+ const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" };
238
+ function unescapeHtml(text) {
239
+ return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m]);
240
+ }
241
+ function run(text, s) {
242
+ return new TextRun({
243
+ text: unescapeHtml(text ?? ''),
244
+ bold: s.bold, italics: s.italics, strike: s.strike,
245
+ ...(s.code ? { font: CODE_FONT, size: CODE_SIZE } : {}),
246
+ });
247
+ }
248
+ function textOf(tokens) {
249
+ return (tokens ?? []).map((t) => ('text' in t ? String(t.text) : ('raw' in t ? String(t.raw) : ''))).join('');
250
+ }
@@ -0,0 +1,54 @@
1
+ // cspeach-cli/src/docx/write-docx.ts
2
+ //
3
+ // The Word copy goes next to its Markdown source with the same base name.
4
+ // Never overwrites (repo convention: projects/save.ts, projects/sort-command.ts):
5
+ // `wx` flag, then -2, -3 … when the name is taken. A file held open by Word
6
+ // or a synced folder (EPERM / EBUSY) is reported once in plain words, never
7
+ // retried in a loop.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { markdownToDocx } from './markdown-to-docx.js';
11
+ const MAX_SUFFIX = 999;
12
+ export function docxPathFor(mdPath) {
13
+ const parsed = path.parse(mdPath);
14
+ const base = parsed.ext.toLowerCase() === '.md' ? parsed.name : parsed.base;
15
+ return path.join(parsed.dir, `${base}.docx`);
16
+ }
17
+ /** The first of `p`, `<stem>-2<ext>`, `-3` … that does not exist. */
18
+ export async function uniquePath(p) {
19
+ const { dir, name, ext } = path.parse(p);
20
+ let candidate = p;
21
+ for (let n = 2; n <= MAX_SUFFIX; n++) {
22
+ if (!fs.existsSync(candidate))
23
+ return candidate;
24
+ candidate = path.join(dir, `${name}-${n}${ext}`);
25
+ }
26
+ if (!fs.existsSync(candidate))
27
+ return candidate;
28
+ throw new Error(`Too many copies of ${path.basename(p)} in ${dir} (${MAX_SUFFIX}). Delete some and try again.`);
29
+ }
30
+ /** Write `data` to `target`, or to the next free -N name if a race took it. */
31
+ export async function writeNoClobber(target, data) {
32
+ for (let attempt = 0; attempt < 5; attempt++) {
33
+ const p = await uniquePath(target);
34
+ try {
35
+ await fs.promises.writeFile(p, data, { flag: 'wx' });
36
+ return p;
37
+ }
38
+ catch (e) {
39
+ const code = e.code;
40
+ if (code === 'EEXIST')
41
+ continue;
42
+ if (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES') {
43
+ throw new Error(`Could not write ${p}: it is open in another program or the folder is read-only. Close it and try again.`);
44
+ }
45
+ throw e;
46
+ }
47
+ }
48
+ throw new Error(`Could not find a free name for ${path.basename(target)}.`);
49
+ }
50
+ export async function writeDocxNextTo(mdPath, markdown, meta) {
51
+ const buf = await markdownToDocx(markdown, meta);
52
+ await fs.promises.mkdir(path.dirname(mdPath), { recursive: true });
53
+ return writeNoClobber(docxPathFor(mdPath), buf);
54
+ }
package/dist/one-shot.js CHANGED
@@ -26,6 +26,8 @@ import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer
26
26
  import { setRenderingOverride, setHeadlessOverride, setOneShotOverride } from './renderer/tty.js';
27
27
  import { resolveRenderingMode } from './renderer/rendering-mode.js';
28
28
  import { setLook, setBackground } from './renderer/look.js';
29
+ import { setAssistantStyle } from './agent/style-state.js';
30
+ import { setAssistantLevel } from './agent/level-state.js';
29
31
  import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
30
32
  import { createClassicOutputEmitter } from './renderer/notices.js';
31
33
  import { AnswerTrim } from './renderer/answer-trim.js';
@@ -49,6 +51,7 @@ import './tools/approval.js';
49
51
  import './tools/snapshot.js';
50
52
  import './tools/ask-question.js';
51
53
  import './tools/dispatch-skill.js';
54
+ import './tools/hand-off.js';
52
55
  import './tools/todo.js';
53
56
  import './tools/filesystem/file-read.js';
54
57
  import './tools/tool-output-read.js';
@@ -100,6 +103,13 @@ export async function runOneShot(prompt, opts) {
100
103
  setOneShotOverride(true);
101
104
  setRenderingOverride(resolveRenderingMode(cfg.ui.rendering).mode);
102
105
  setLook(cfg.ui.look);
106
+ // Chat lane D-D/D9 — the talking style rides on one-shot workflow turns too
107
+ // (professional = no header, so the default request is unchanged). The
108
+ // chat lane itself never runs here: one-shot keeps today's routing.
109
+ // Read defensively: a config without an `assistant` table (older test
110
+ // doubles, a hand-built cfg) means the defaults.
111
+ setAssistantStyle(cfg.assistant?.style ?? 'professional');
112
+ setAssistantLevel(cfg.assistant?.level ?? 'developer'); // §10 D14 — same rule as the style
103
113
  setBackground(cfg.ui.background);
104
114
  // model-governance step 2d — fetch the served model-config (managed mode
105
115
  // only). Task 3: awaited, but startup waits at most 1.5 s (then a fresh disk
@@ -0,0 +1,62 @@
1
+ // cspeach-cli/src/projects/document-save.ts
2
+ //
3
+ // The save for the two document skills (2026-10-19, owner decision 2026-10-17).
4
+ // A review or a handover is prose, not an envelope: it goes to
5
+ // <cwd>/cspeach-out/<date>-<kind>-<OBJECT>.md (the folder the model already
6
+ // writes extra copies to; skipped by the @-picker; exempt from the Rule 8
7
+ // card). Then one question, "Word copy too?", unless `word_copy` decides.
8
+ // Never overwrites. Never throws: a failure is one line through `emit`.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import chalk from 'chalk';
12
+ import { askYesNo, isYes } from './yes-no.js';
13
+ import { metaFromMarkdown } from '../docx/doc-meta.js';
14
+ import { writeDocxNextTo, writeNoClobber } from '../docx/write-docx.js';
15
+ import { safeFileStem } from '../register/xlsx.js';
16
+ import { DOCUMENT_SKILLS } from './document-skills.js';
17
+ export { DOCUMENT_SKILLS };
18
+ export const OUT_DIR = 'cspeach-out';
19
+ export function documentFilename(kind, object, date) {
20
+ const stem = safeFileStem((object ?? 'document').replace(/\//g, ''));
21
+ return `${date}-${kind}-${stem}.md`;
22
+ }
23
+ export async function offerDocumentSave(p) {
24
+ const skill = DOCUMENT_SKILLS[p.skill];
25
+ if (!skill || p.markdown.trim().length === 0)
26
+ return {};
27
+ const meta = metaFromMarkdown(p.markdown, { date: p.today, kind: skill.kind, ...(p.sapSystem ? { system: p.sapSystem } : {}) });
28
+ if (!p.headless) {
29
+ const a = await askYesNo({ question: `Save this ${skill.noun} to a file?`, confirm: p.confirm });
30
+ if (!isYes(a))
31
+ return {};
32
+ }
33
+ else {
34
+ p.emit(chalk.dim(`headless: saving the ${skill.noun} without asking`));
35
+ }
36
+ let mdPath;
37
+ try {
38
+ const target = path.join(p.cwd, OUT_DIR, documentFilename(skill.kind, meta.object, p.today));
39
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
40
+ mdPath = await writeNoClobber(target, p.markdown);
41
+ }
42
+ catch (e) {
43
+ p.emit(`Not saved. ${e.message}`);
44
+ return {};
45
+ }
46
+ p.emit(`Saved: ${path.resolve(mdPath)}`);
47
+ let wantWord = p.wordCopy === 'always';
48
+ if (p.wordCopy === 'ask' && !p.headless) {
49
+ wantWord = isYes(await askYesNo({ question: 'Word copy too?', confirm: p.confirm }));
50
+ }
51
+ if (!wantWord)
52
+ return { mdPath };
53
+ try {
54
+ const docxPath = await writeDocxNextTo(mdPath, p.markdown, meta);
55
+ p.emit(`Word copy: ${path.resolve(docxPath)}`);
56
+ return { mdPath, docxPath };
57
+ }
58
+ catch (e) {
59
+ p.emit(`Word copy not written. ${e.message}`);
60
+ return { mdPath };
61
+ }
62
+ }
@@ -0,0 +1,9 @@
1
+ // cspeach-cli/src/projects/document-skills.ts
2
+ //
3
+ // The two document skills, on their own so the agent loop can check a skill
4
+ // name without loading document-save.ts (and the docx package behind it) on
5
+ // every turn. Type-only import: nothing heavy loads from here.
6
+ export const DOCUMENT_SKILLS = {
7
+ 'abap-review': { kind: 'review', noun: 'review' },
8
+ 'abap-handover': { kind: 'handover', noun: 'handover' },
9
+ };
@@ -38,13 +38,14 @@ export function footerStateFrom(s) {
38
38
  user: s.user || undefined,
39
39
  transport: (s.transport ?? s.lastWriteTransport ?? s.confirmedTransport) || undefined,
40
40
  standalone: s.standalone === true,
41
+ chat: s.chatLane === true,
41
42
  mode: s.writeMode ?? '',
42
43
  spend: formatSpendSegment(s),
43
44
  };
44
45
  }
45
46
  const MODE_ROLE = { 'advisory-only': 'warn', 'approval-gated': 'ok', auto: 'sap' };
46
47
  /**
47
- * `<SYS> · <USER> · transport <TR> · <mode> · <spend>` on the left and a dim
48
+ * `<SYS> · <USER> · [chat ·] transport <TR> · <mode> · <spend>` on the left and a dim
48
49
  * `? shortcuts` on the right, padded to exactly `width`. When it does not fit,
49
50
  * the hint drops first, then the left side truncates.
50
51
  */
@@ -52,6 +53,8 @@ export function formatFooterLine(s, width) {
52
53
  const parts = [];
53
54
  if (s.standalone) {
54
55
  parts.push({ text: 'standalone', role: 'sap' }, { text: 'local files', role: 'dim' });
56
+ if (s.chat)
57
+ parts.push({ text: 'chat', role: 'sap' });
55
58
  }
56
59
  else {
57
60
  // System, user and transport come from config / SAP responses (the
@@ -60,6 +63,8 @@ export function formatFooterLine(s, width) {
60
63
  parts.push(s.system ? { text: sanitizeControlChars(s.system), role: 'sap' } : { text: 'no system', role: 'dim' });
61
64
  if (s.user)
62
65
  parts.push({ text: sanitizeControlChars(s.user), role: 'dim' });
66
+ if (s.chat)
67
+ parts.push({ text: 'chat', role: 'sap' });
63
68
  parts.push({ text: s.transport ? `transport ${sanitizeControlChars(s.transport)}` : 'no transport', role: 'dim' });
64
69
  }
65
70
  if (s.mode)
@@ -10,6 +10,7 @@ import { panelRow } from './panel-rows.js';
10
10
  import { paint } from './theme.js';
11
11
  import { formatInfo } from './notice-shape.js';
12
12
  import { sanitizeControlChars } from './sanitize.js';
13
+ import { creditsActive } from '../cost/credits.js';
13
14
  const LABEL = 10; // 'Last skill'
14
15
  /**
15
16
  * `logDir` — the display path of this session's turn-stream folder, passed
@@ -25,7 +26,9 @@ export function formatGoodbye(p) {
25
26
  chalk.bold(`Session ${id}`),
26
27
  panelRow('Turns', String(p.turns), LABEL),
27
28
  panelRow('Last skill', skill, LABEL),
28
- panelRow('Tokens', `${p.tokens.toLocaleString('en-US')} (input + output + cache)`, LABEL),
29
+ // Credits mode: no token count beside credits (owner 2026-10-02 for /cost
30
+ // and the footer; 2026-10-19 here) — it invites "credits per token" maths.
31
+ ...(creditsActive() ? [] : [panelRow('Tokens', `${p.tokens.toLocaleString('en-US')} (input + output + cache)`, LABEL)]),
29
32
  panelRow('Spend', p.spend, LABEL),
30
33
  '',
31
34
  // Round 6 review I1 — a session never written (no turns) has nothing to resume.
@@ -109,6 +109,8 @@ export function aliasSourceNote(source, alias) {
109
109
  }
110
110
  export function panelsStatusLine(p) {
111
111
  const segs = [p.standalone ? paint('dim', 'standalone (no SAP system)') : paint('sap', p.system)];
112
+ if (p.chat)
113
+ segs.push(paint('sap', 'chat'));
112
114
  if (p.tools === 'files+commands')
113
115
  segs.push(paint('warn', 'local files + commands on'));
114
116
  else if (p.tools === 'files')
@@ -120,10 +122,12 @@ export function panelsStatusLine(p) {
120
122
  return ' ' + segs.join(paint('dim', ' · '));
121
123
  }
122
124
  /** Line 3 of the panels start screen (non-resume). */
123
- export function panelsHintLine(tools) {
125
+ export function panelsHintLine(tools, lane = 'skills') {
124
126
  const text = tools === 'files+commands'
125
127
  ? 'turn off commands: cspeach config set local_build off · ? for shortcuts'
126
- : '? for shortcuts · /help for commands';
128
+ : lane === 'chat'
129
+ ? 'Just talk to me · use /abap-… when you want a specific guided workflow · /help for commands'
130
+ : '? for shortcuts · /help for commands';
127
131
  return ' ' + paint('dim', text);
128
132
  }
129
133
  /** Line 3 of the panels start screen on --resume: the recap's "last session" fact.
@@ -199,6 +199,7 @@ const RULES = {
199
199
  ask_question: (a) => ({ verb: 'Question', object: shortRight(str(a['question']) ?? '') }),
200
200
  todo_set: () => ({ verb: 'Updated todos', object: '' }),
201
201
  dispatch_skill: (a) => ({ verb: 'Ran skill', object: shortRight(str(a['command']) ?? '') }),
202
+ hand_off: (a) => ({ verb: 'Handed off', object: shortRight(`/${(str(a['skill']) ?? '').replace(/^\//, '')}`) }),
202
203
  read_document: (a, r) => ({ verb: 'Read document', object: shortLeft(str(a['path']) ?? ''), ...(r !== undefined ? { detail: `${lineCount(r)} lines` } : {}) }),
203
204
  web_fetch: (a) => ({ verb: 'Fetched', object: shortRight(str(a['url']) ?? '') }),
204
205
  web_search: (a) => ({ verb: 'Searched web', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
@@ -276,7 +277,7 @@ const PRESENT = {
276
277
  'Created data element': 'Creating data element', 'Read BAdI': 'Reading BAdI', 'Read enhancement options': 'Reading enhancement options of', 'Created BAdI implementation': 'Creating BAdI implementation', 'Created table type': 'Creating table type', 'Read table type': 'Reading table type', 'Read transport request': 'Reading transport request', 'Listed function group': 'Listing function group', Locked: 'Locking', Unlocked: 'Unlocking',
277
278
  Published: 'Publishing', Unpublished: 'Unpublishing', Messages: 'Maintaining messages in',
278
279
  'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
279
- 'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
280
+ 'Ran skill': 'Running skill', 'Handed off': 'Handing off', 'Read document': 'Reading document', Fetched: 'Fetching',
280
281
  'Searched web': 'Searching web', 'Read project context': 'Reading project context',
281
282
  'Read playbook': 'Reading playbook', 'Read convention': 'Reading convention', 'Merged CCA': 'Merging CCA',
282
283
  'Merged progress': 'Merging progress', 'Checked background': 'Checking background',
@@ -48,8 +48,12 @@ const peach = (s) => chalk.hex(PEACH)(s);
48
48
  * block is the representation, so the raw `⏺ todo_set({"todos":[…])`
49
49
  * line + ⎿ JSON result are suppressed. Owner feedback drove this: the
50
50
  * transcript should show the full checklist, not a JSON tool line.
51
+ * - hand_off: the chat lane queues the workflow with one dim `→ /abap-x`
52
+ * line (queued-step origin, both REPLs) — that line is the
53
+ * representation, so a "Handed off /abap-x" row would only double it.
54
+ * A refused hand_off still shows its ✗ row.
51
55
  */
52
- const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set']);
56
+ const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set', 'hand_off']);
53
57
  export function isWidgetSuppressedTool(name) {
54
58
  return WIDGET_SUPPRESSED_TOOLS.has(name);
55
59
  }
@@ -33,7 +33,7 @@ export const BUILTIN_ENTRIES = [
33
33
  { name: '/cost', get description() { return costCommandDescription(); } },
34
34
  { name: '/usage', get description() { return usageCommandDescription(); } },
35
35
  { name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
36
- { name: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
36
+ { name: '/export', description: 'Export the session audit record (/export audit [filename]) or a Word copy of a saved file (/export docx <file>.md)' },
37
37
  { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
38
38
  { name: '/exit', description: 'Exit CSPeach' },
39
39
  { name: '/quit', description: 'Exit CSPeach (alias)' },
@@ -41,10 +41,13 @@ export const BUILTIN_ENTRIES = [
41
41
  { name: '/skills', description: 'Show all skills (alias)' },
42
42
  { name: '/mode', description: 'Cycle or set the session write mode (advisory / gated / auto) — config unchanged' },
43
43
  { name: '/new', description: 'Reset routing — next prompt is classified afresh' },
44
+ { name: '/chat', description: 'Chat mode — /chat on · /chat off · /chat to leave a workflow' },
44
45
  { name: '/recap', description: 'Show the session recap (last session · open tasks · plan progress)' },
45
46
  { name: '/reset', description: 'Reset routing (alias)' },
46
47
  { name: '/rewind', description: 'Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)' },
47
48
  { name: '/ui', description: 'View or change rendering mode (auto / ink), or the look: /ui look panels|legacy' },
49
+ { name: '/style', description: 'View or change the talking style: professional (default), conversational, humor or blunt' },
50
+ { name: '/level', description: 'Who CSPeach is talking to: developer (default), learning or functional' },
48
51
  { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
49
52
  { name: '/transport', description: 'View or set the active transport for this session' },
50
53
  { name: '/rules', description: 'View or change the decision rules of the joined team project (the lead changes them)' },
@@ -0,0 +1,64 @@
1
+ import { applyLaneTools } from './chat-lane.js';
2
+ import { clearAppFlow } from '../lane/app-flow.js';
3
+ import { clearJourneyFlow } from '../journey/flow.js';
4
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
5
+ import { markExplicit } from '../commands/config-set.js';
6
+ export const CHAT_BACK_LINE = 'Back in chat · just talk, or /abap-… for a guided workflow · /chat off for skill routing';
7
+ export const CHAT_ON_LINE = 'Chat is on · just talk, or /abap-… for a guided workflow';
8
+ export const CHAT_ON_PIPED_LINE = 'Chat is on · it applies in an interactive terminal';
9
+ export const CHAT_OFF_LINE = 'Skill routing is on · every message picks a skill again · /chat on switches back';
10
+ export const CHAT_SKILLS_LANE_LINE = 'Skill routing is on · /chat on switches to chat';
11
+ export const CHAT_USAGE_LINE = 'Use /chat, /chat on or /chat off';
12
+ /** Leave the active workflow: the next message goes to chat (D6). */
13
+ function leaveWorkflow(session) {
14
+ session.skill = null;
15
+ session.awaitingSkillAnswer = false;
16
+ clearAppFlow(session);
17
+ clearJourneyFlow(session);
18
+ }
19
+ /** Returns the lane in force after the command. */
20
+ export async function handleChatCommand(arg, d) {
21
+ const a = arg.trim().toLowerCase();
22
+ const say = (s) => d.emit(formatInfo(s).trimEnd());
23
+ if (a === '') {
24
+ if (d.lane === 'chat') {
25
+ leaveWorkflow(d.session);
26
+ say(CHAT_BACK_LINE);
27
+ }
28
+ else
29
+ say(CHAT_SKILLS_LANE_LINE);
30
+ return d.lane;
31
+ }
32
+ if (a !== 'on' && a !== 'off') {
33
+ say(CHAT_USAGE_LINE);
34
+ return d.lane;
35
+ }
36
+ const next = a === 'on' ? 'chat' : 'skills';
37
+ // Already on with a workflow running: same as bare /chat — back in chat.
38
+ const hadWorkflow = !!d.session.skill;
39
+ if (next === 'chat')
40
+ leaveWorkflow(d.session);
41
+ else if (next !== d.lane)
42
+ d.session.awaitingSkillAnswer = false; // review E — a chat turn's question never carries into routing
43
+ if (next === 'chat' && d.lane === 'chat' && hadWorkflow) {
44
+ say(CHAT_BACK_LINE);
45
+ return next;
46
+ }
47
+ if (next !== d.lane) {
48
+ d.cfg.assistant.lane = next;
49
+ // Spec D2: an explicit switch is saved explicitly, even when it equals
50
+ // today's default, so a later release's default flip never overrides it.
51
+ markExplicit(d.cfg, 'assistant.lane');
52
+ // In static tool mode the session's tools array grows once here (hand_off);
53
+ // /chat off never removes it — hand_off is refused outside the chat lane.
54
+ applyLaneTools(next, d.interactive);
55
+ try {
56
+ await d.save(d.cfg);
57
+ }
58
+ catch (err) {
59
+ d.emit(formatFailure({ what: `Failed to save config: ${err.message} (applied for this session only)` }).trimEnd());
60
+ }
61
+ }
62
+ say(next === 'chat' ? (d.interactive ? CHAT_ON_LINE : CHAT_ON_PIPED_LINE) : CHAT_OFF_LINE);
63
+ return next;
64
+ }