@cspeach/cli 1.1.53 → 1.1.55

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 (58) hide show
  1. package/dist/agent/loop.js +90 -7
  2. package/dist/agent/tool-dispatch.js +11 -0
  3. package/dist/auth/auth-file.js +3 -0
  4. package/dist/auth/key-resolver.js +8 -3
  5. package/dist/auth/me.js +6 -2
  6. package/dist/cli.js +25 -0
  7. package/dist/commands/login.js +22 -4
  8. package/dist/commands/plan-reground.js +5 -0
  9. package/dist/commands/sap-link.js +14 -1
  10. package/dist/commands/team-build-help.js +19 -0
  11. package/dist/commands/team-build.js +97 -0
  12. package/dist/commands/team-plan.js +2 -2
  13. package/dist/commands/team-spec.js +107 -0
  14. package/dist/commands/team.js +111 -4
  15. package/dist/journey/chain.js +8 -0
  16. package/dist/journey/clean-core.js +2 -2
  17. package/dist/journey/rebuild.js +8 -0
  18. package/dist/journey/run.js +9 -0
  19. package/dist/lane/ready-spec.js +32 -0
  20. package/dist/licence/account-name.js +31 -0
  21. package/dist/licence/paths.js +5 -0
  22. package/dist/licence/seat-kind.js +110 -0
  23. package/dist/licence/seat-state.js +4 -0
  24. package/dist/licence/terminal-seat.js +43 -0
  25. package/dist/licence/turn-gate.js +4 -0
  26. package/dist/one-shot.js +32 -0
  27. package/dist/projects/answer-blockers.js +31 -7
  28. package/dist/projects/blocker-answers-note.js +1 -1
  29. package/dist/projects/save-command.js +37 -2
  30. package/dist/projects/saved-lines.js +19 -17
  31. package/dist/projects/spec-share.js +270 -0
  32. package/dist/projects/team-build-context.js +49 -0
  33. package/dist/register/picture.js +2 -2
  34. package/dist/register/store.js +19 -0
  35. package/dist/renderer/routing-line.js +11 -0
  36. package/dist/repl/builtin-commands.js +4 -3
  37. package/dist/repl/file-picker.js +52 -5
  38. package/dist/repl/mode-cycle.js +12 -0
  39. package/dist/repl/open-questions-gate.js +193 -0
  40. package/dist/repl/ready-phrase.js +108 -0
  41. package/dist/repl/startup-account.js +64 -1
  42. package/dist/repl/team-build-screen.js +124 -0
  43. package/dist/repl/team-build-start.js +179 -0
  44. package/dist/repl.js +227 -10
  45. package/dist/sap-link/policy.js +4 -0
  46. package/dist/sap-link/registry.js +9 -3
  47. package/dist/sap-link/server.js +4 -1
  48. package/dist/tools/_team-work-path.js +25 -1
  49. package/dist/tools/cap/sap-link-tool.js +2 -0
  50. package/dist/tools/filesystem/file-edit.js +2 -2
  51. package/dist/tools/filesystem/file-write.js +2 -2
  52. package/dist/tools/hand-off.js +16 -1
  53. package/dist/tools/project/spec_answers_record.js +62 -9
  54. package/dist/tools/project/team_findings.js +2 -2
  55. package/dist/tools/seat-gate.js +41 -0
  56. package/dist/ui/when-listening.js +34 -0
  57. package/dist/ui/widgets/files-card.js +19 -1
  58. package/package.json +1 -1
@@ -138,7 +138,7 @@ export function pointsIntoSharedCspeach(cwd, realTarget) {
138
138
  * Windows opens them, so `LANES.JSON`, `plan\x.json` or `lanes.json.` count.
139
139
  */
140
140
  const TEAM_OWNED_FILES = ['cspeach-team.json', 'lanes.json'];
141
- const TEAM_OWNED_DIRS = ['writers', 'rulings', 'plan', 'baseline'];
141
+ const TEAM_OWNED_DIRS = ['writers', 'rulings', 'plan', 'baseline', 'specs'];
142
142
  export function isTeamOwnedRel(rel) {
143
143
  const segs = rel.split(/[\\/]+/).filter((s) => s !== '' && s !== '.');
144
144
  if (!segs.length)
@@ -150,6 +150,30 @@ export function isTeamOwnedRel(rel) {
150
150
  }
151
151
  /** The refusal for a model write to one of the team project's own files. */
152
152
  export const TEAM_FILE_REFUSAL = 'This file belongs to the project and CSPeach writes it. Say what you want changed instead.';
153
+ /**
154
+ * F5 — a write into a team build's shared specs/ folder: the refusal names
155
+ * the route, so the model takes it on the same turn instead of saving the
156
+ * spec somewhere else.
157
+ */
158
+ export const SPECS_REFUSAL = 'Specs in this folder are written and shared by CSPeach, never by a file write. For a new or changed spec, hand_off with skill "abap-spec-gap"; to build a ready spec, hand_off with skill "abap-app".';
159
+ /** The refusal for a team-owned target: the specs/ route, or today's line. */
160
+ export function teamFileRefusalFor(cwd, realTarget) {
161
+ const root = teamWorkRoot(cwd);
162
+ if (root === null)
163
+ return TEAM_FILE_REFUSAL;
164
+ const realOf = (p) => { try {
165
+ return realpathSync.native(p);
166
+ }
167
+ catch {
168
+ return p;
169
+ } };
170
+ for (const specs of new Set([path.join(path.resolve(cwd), 'specs'), realOf(path.join(path.resolve(cwd), 'specs'))])) {
171
+ const rel = path.relative(specs, realTarget);
172
+ if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel))
173
+ return SPECS_REFUSAL;
174
+ }
175
+ return TEAM_FILE_REFUSAL;
176
+ }
153
177
  /**
154
178
  * True when `cwd` is a team folder and the REAL landing path of a write lies
155
179
  * on one of its own files (isTeamOwnedRel), whatever spelling, link or short
@@ -115,6 +115,8 @@ registerTool({
115
115
  `SAP link ${r.reused ? 'already on' : 'on'}: ${r.handle.url} → ${ctx.sapAlias}`,
116
116
  `Services: ${r.services.join(', ')}`,
117
117
  ];
118
+ if (r.seatNote)
119
+ lines.push(r.seatNote);
118
120
  if (r.roleNote)
119
121
  lines.push(r.roleNote);
120
122
  if (r.tlsNote)
@@ -15,7 +15,7 @@ import { promises as fs } from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import { registerTool } from '../index.js';
17
17
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
18
- import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, TEAM_FILE_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
18
+ import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamFileRefusalFor, teamWorkTarget } from '../_team-work-path.js';
19
19
  export async function fileEditHandler(args, ctx) {
20
20
  // 1. Validate args before any IO
21
21
  if (args.old_string === args.new_string) {
@@ -79,7 +79,7 @@ export async function fileEditHandler(args, ctx) {
79
79
  // write. Judged on the real landing path. A folder without a team
80
80
  // project never gets here.
81
81
  if (pointsAtTeamFile(ctx.cwd, realAbs)) {
82
- return { content: `error: ${TEAM_FILE_REFUSAL}`, is_error: true };
82
+ return { content: `error: ${teamFileRefusalFor(ctx.cwd, realAbs)}`, is_error: true };
83
83
  }
84
84
  // 5. Read the file
85
85
  let content;
@@ -19,7 +19,7 @@ import { promises as fs, readFileSync } from 'node:fs';
19
19
  import * as path from 'node:path';
20
20
  import { registerTool } from '../index.js';
21
21
  import { resolveSafePath, realPathForWrite, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
22
- import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, TEAM_FILE_REFUSAL, teamWorkTarget } from '../_team-work-path.js';
22
+ import { pointsAtTeamFile, pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamFileRefusalFor, teamWorkTarget } from '../_team-work-path.js';
23
23
  /**
24
24
  * 2026-09-23 (Glenn's first run) — a standalone run writes GUIDE.md and said
25
25
  * nothing about it. He opened it in a text editor, then tried to drop it into
@@ -112,7 +112,7 @@ export async function fileWriteHandler(args, ctx) {
112
112
  // write. Judged on the real landing path. A folder without a team
113
113
  // project never gets here.
114
114
  if (pointsAtTeamFile(ctx.cwd, target)) {
115
- return { content: `error: ${TEAM_FILE_REFUSAL}`, is_error: true };
115
+ return { content: `error: ${teamFileRefusalFor(ctx.cwd, target)}`, is_error: true };
116
116
  }
117
117
  // 4. Ensure parent directory exists (no-op if it already does)
118
118
  try {
@@ -107,7 +107,22 @@ registerTool({
107
107
  if (request.length > HAND_OFF_REQUEST_MAX) {
108
108
  return bad('HAND_OFF_INVALID', `request is too long (${request.length} chars; at most ${HAND_OFF_REQUEST_MAX}). Say it in one short sentence with the object names.`);
109
109
  }
110
- const command = buildHandOffCommand(skill, request);
110
+ let command = buildHandOffCommand(skill, request);
111
+ // F9a (2026-10-29 live run): in a team build folder a hand-off to the
112
+ // build workflow carries the ready spec, so the app flow starts at the
113
+ // lane step instead of refusing for want of an @file.
114
+ if (skill === 'abap-app' && !request.includes('@')) {
115
+ const { readySpecFor, notReadyLine } = await import('../lane/ready-spec.js');
116
+ const picked = await readySpecFor(ctx.cwd ?? process.cwd(), request);
117
+ if (picked && 'notReady' in picked) {
118
+ return bad('HAND_OFF_SPEC_NOT_READY', `${notReadyLine(picked.notReady)} Tell the person; do not build another spec instead.`);
119
+ }
120
+ if (picked && 'ask' in picked) {
121
+ return bad('HAND_OFF_SPEC_NEEDED', `more than one spec here is ready to build: ${picked.ask.join('; ')}. Ask which one, then hand off with its title in the request.`);
122
+ }
123
+ if (picked)
124
+ command = picked.command;
125
+ }
111
126
  if (isPlanResumeCommand(command)) {
112
127
  return bad('PLAN_RESUME_HARNESS_OWNED', 'Plan continuation is owned by CSPeach; never hand off "/abap-plan --resume".');
113
128
  }
@@ -17,6 +17,11 @@
17
17
  * shared team `.cspeach` (teamReadTarget's fallback there is read-only), and
18
18
  * only a spec-gap envelope.
19
19
  *
20
+ * 2026-10-28: a spec shared in a team build (`specs/<slug>/<name>.cspeach.json`)
21
+ * is answered with the blocker form's record mode: the revision is validated,
22
+ * each answer becomes its own record in `specs/<slug>/answers/`, and the
23
+ * author's file is never written.
24
+ *
20
25
  * Every refusal is one line and writes nothing: a sub-agent or reader child,
21
26
  * a path outside the project, a missing file, a file that is not a spec-gap
22
27
  * envelope in `.cspeach/`, the shared team `.cspeach`, an unknown id, a blank
@@ -34,7 +39,9 @@ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isReadO
34
39
  import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamReadTarget } from '../_team-work-path.js';
35
40
  import { SPEC_ANSWERS_RECORD_TOOL } from '../../projects/blocker-answers-note.js';
36
41
  import { readProjectFile } from '../../projects/status.js';
37
- import { openBlockers, saveBlockerAnswers } from '../../projects/answer-blockers.js';
42
+ import { applyBlockerAnswers, openBlockers, saveBlockerAnswers } from '../../projects/answer-blockers.js';
43
+ import { validateEnvelope } from '../../projects/validate.js';
44
+ import { composeSpec, isSharedSpecPath, readAnswers, sharedSpecTargetFor, specAuthorFor, writeAnswerRecord, SPECS_DIR, } from '../../projects/spec-share.js';
38
45
  export const SPEC_ANSWERS_RECORD = SPEC_ANSWERS_RECORD_TOOL;
39
46
  /** Fix round 1 (item 4): the longest answer recorded. */
40
47
  export const MAX_ANSWER = 2000;
@@ -58,6 +65,14 @@ function realOr(p) {
58
65
  return path.resolve(p);
59
66
  }
60
67
  }
68
+ /** The build project when `real` is a spec right inside `<team>/specs/<slug>/`, else null. */
69
+ function sharedSpecFile(cwd, real) {
70
+ const target = sharedSpecTargetFor(cwd, 'spec-gap');
71
+ if (!target || !isSharedSpecPath(target.teamDir, real) || !/\.cspeach\.json$/i.test(real))
72
+ return null;
73
+ const rel = path.relative(realOr(path.join(target.teamDir, SPECS_DIR)), real);
74
+ return rel.split(path.sep).length === 2 ? { projectId: target.projectId } : null;
75
+ }
61
76
  export async function specAnswersRecordHandler(args, ctx) {
62
77
  // Fix round 1 (item 5): only the main conversation records answers (a child could race the form).
63
78
  if (ctx.subagent || ctx.readerMode)
@@ -83,9 +98,13 @@ export async function specAnswersRecordHandler(args, ctx) {
83
98
  // fallback to it is for reading only, as for the write tools.
84
99
  if (pointsIntoSharedCspeach(ctx.cwd, real))
85
100
  return fail(`${SHARED_CSPEACH_REFUSAL} ${NOTHING_SAVED}`);
101
+ // 2026-10-28 — a spec shared in a team build (specs/<slug>/<name>.cspeach.json)
102
+ // is answered like the blocker form answers it there: one answer record per
103
+ // question beside it, the author's file never rewritten.
104
+ const shared = sharedSpecFile(ctx.cwd, real);
86
105
  // Fix round 1 (item 3): only a saved result the CLI writes (.cspeach/<name>.cspeach.json),
87
106
  // the same shape file_read's denylist carve-out opens (lane files excluded).
88
- if (!isReadOnlyProjectFile(real, t.root) || !/\.cspeach\.json$/i.test(real)) {
107
+ if (!shared && (!isReadOnlyProjectFile(real, t.root) || !/\.cspeach\.json$/i.test(real))) {
89
108
  return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
90
109
  }
91
110
  if (!(await fs.stat(real)).isFile())
@@ -93,6 +112,9 @@ export async function specAnswersRecordHandler(args, ctx) {
93
112
  let env;
94
113
  try {
95
114
  env = readProjectFile(real);
115
+ // R11: only the answers recorded for this envelope.
116
+ if (shared)
117
+ env = composeSpec(env, readAnswers(path.dirname(real)).filter((a) => a.file === path.basename(real)));
96
118
  }
97
119
  catch {
98
120
  return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
@@ -138,15 +160,46 @@ export async function specAnswersRecordHandler(args, ctx) {
138
160
  }
139
161
  if (problems.length > 0)
140
162
  return fail(`${problems.join('; ')}. ${NOTHING_SAVED}`);
141
- // The form's own save step: apply with the session identity, validate, write in place.
142
- const saved = saveBlockerAnswers(real, env, answers, await sessionIdentity(), new Date().toISOString());
143
- if (!saved.ok)
144
- return fail(`the revised file did not validate (${saved.error}). ${NOTHING_SAVED}`);
163
+ const identity = await sessionIdentity();
164
+ const nowIso = new Date().toISOString();
165
+ let revised;
166
+ if (shared) {
167
+ // The blocker form's record mode: check the revision, write records, never the file.
168
+ revised = applyBlockerAnswers(env, answers, identity, nowIso);
169
+ const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
170
+ if (!check.ok)
171
+ return fail(`the revised file did not validate (${check.error.message}). ${NOTHING_SAVED}`);
172
+ const specDir = path.dirname(real);
173
+ const by = specAuthorFor(shared.projectId, identity.name);
174
+ const done = [];
175
+ try {
176
+ for (const [gapId, answer] of answers) {
177
+ writeAnswerRecord(specDir, {
178
+ projectId: shared.projectId, slug: path.basename(specDir), file: path.basename(real), gapId, answer, by, at: nowIso,
179
+ });
180
+ done.push(gapId);
181
+ }
182
+ }
183
+ catch (err) {
184
+ // A shared folder can fail part-way (full, read-only, sync lock): say what was recorded.
185
+ const why = err instanceof Error ? err.message : String(err);
186
+ return fail(done.length === 0
187
+ ? `could not write the answer records (${why}). ${NOTHING_SAVED}`
188
+ : `could not write every answer record (${why}). Recorded: ${done.join(', ')}. The others were not saved.`);
189
+ }
190
+ }
191
+ else {
192
+ // The form's own save step: apply with the session identity, validate, write in place.
193
+ const saved = saveBlockerAnswers(real, env, answers, identity, nowIso);
194
+ if (!saved.ok)
195
+ return fail(`the revised file did not validate (${saved.error}). ${NOTHING_SAVED}`);
196
+ revised = saved.revised;
197
+ }
145
198
  const rel = path.relative(realOr(t.root), real).split(path.sep).join('/');
146
- const open = openBlockers(saved.revised);
199
+ const open = openBlockers(revised);
147
200
  return {
148
201
  content: [
149
- `Saved in ${rel}.`,
202
+ shared ? `Recorded for ${rel} (the spec file itself is unchanged).` : `Saved in ${rel}.`,
150
203
  ...order.map((id) => `${id}: recorded.`),
151
204
  open.length === 0
152
205
  ? 'No blockers are still open.'
@@ -157,7 +210,7 @@ export async function specAnswersRecordHandler(args, ctx) {
157
210
  registerTool({
158
211
  name: SPEC_ANSWERS_RECORD,
159
212
  description: 'Record answers the developer gives in chat to spec-gap questions, in the saved spec-gap file '
160
- + '(.cspeach/<name>.cspeach.json): blockers or any question in the file. The file exists only once the document has been saved, after your turn. Give each question id as in the file '
213
+ + '(.cspeach/<name>.cspeach.json, or in a team build specs/<slug>/<name>.cspeach.json): blockers or any question in the file. The file exists only once the document has been saved, after your turn. Give each question id as in the file '
161
214
  + '(for example gap-004) with its answer, at most 2,000 characters. '
162
215
  + 'Answering a question that already has an answer replaces it. Nothing is saved if any id or answer is refused. '
163
216
  + 'Never edit .cspeach.json files with shell or file-edit tools. No SAP call.',
@@ -30,7 +30,7 @@ import { registerTool } from '../index.js';
30
30
  import { findingsByObject, shownUpTo } from '../../register/findings.js';
31
31
  import { registerFor } from '../../register/picture.js';
32
32
  import { ruleProposalOf } from '../../register/decision-rules.js';
33
- import { activeProject, readProjectFile } from '../../register/store.js';
33
+ import { activeProject, coreProject, readProjectFile } from '../../register/store.js';
34
34
  import { shown } from '../../register/atc-file.js';
35
35
  import { journeyPage } from '../../journey/flow.js';
36
36
  import { NOT_JOINED_LINE } from './_team-tool-shared.js';
@@ -98,7 +98,7 @@ export async function teamFindingsHandler(args, ctx) {
98
98
  const laneId = args.lane?.trim();
99
99
  const patterns = (args.packages ?? '').split(',').map((s) => s.trim()).filter(Boolean);
100
100
  // The project view, built once: the scope, its parts and every row read it.
101
- const view = projectView(register, project);
101
+ const view = projectView(register, coreProject(project));
102
102
  let objects = view.all.objects;
103
103
  let scope = { kind: 'project', name: project.name };
104
104
  if (laneId) {
@@ -0,0 +1,41 @@
1
+ import { WRITE_TOOLS } from './write-mode.js';
2
+ export const SEAT_READ_ONLY_REASON = 'This seat reads SAP and never changes it. Describe the change in plain words, or write it up for the developer as cspeach-out/developer-request-<topic>.md. Do not call SAP write, transport, lock, run, test, ATC or approval tools.';
3
+ /**
4
+ * Refused beyond the generic rule: the two smokes write SAP data over OData
5
+ * although requiresSap is false; unit tests and ATC runs execute on SAP;
6
+ * team_lane_move writes the team project's lanes; fiori_deploy uploads an app.
7
+ * shell_exec and background_run (ruling R8): their safelist runs npm, pnpm,
8
+ * ui5 and node, a deploy route around the fiori_deploy refusal.
9
+ */
10
+ export const SEAT_EXTRA_REFUSED = new Set([
11
+ 'fiori_draft_smoke',
12
+ 'cap_link_smoke',
13
+ 'sap_run_unit_test',
14
+ 'team_lane_move',
15
+ 'sap_atc_run',
16
+ 'fiori_deploy',
17
+ 'shell_exec',
18
+ 'background_run',
19
+ ]);
20
+ export function isSeatRefusedTool(tool) {
21
+ return WRITE_TOOLS.has(tool.name) || tool.name === 'request_approval' || SEAT_EXTRA_REFUSED.has(tool.name)
22
+ || (tool.isMutating === true && tool.requiresSap === true);
23
+ }
24
+ /** The model-facing refusal for this call on this seat, or null when it may run. */
25
+ export function seatRefusal(kind, tool, args) {
26
+ if (kind !== 'functional')
27
+ return null;
28
+ if (isSeatRefusedTool(tool))
29
+ return SEAT_READ_ONLY_REASON;
30
+ // No approval minted elsewhere can ever be replayed from a functional seat.
31
+ if (args && typeof args === 'object'
32
+ && (Object.prototype.hasOwnProperty.call(args, 'approval_id') || Object.prototype.hasOwnProperty.call(args, 'approval_ids')))
33
+ return SEAT_READ_ONLY_REASON;
34
+ return null;
35
+ }
36
+ /** The tools this seat is offered. Developers: the same array, untouched. */
37
+ export function toolsForSeat(tools, kind) {
38
+ if (kind !== 'functional')
39
+ return tools;
40
+ return tools.filter((t) => !isSeatRefusedTool(t));
41
+ }
@@ -0,0 +1,34 @@
1
+ /** Resolves true once `event` has a listener on `emitter`, false after `timeoutMs`. */
2
+ export function whenListening(emitter, event, timeoutMs = 3000) {
3
+ if (emitter.listenerCount(event) > 0)
4
+ return Promise.resolve(true);
5
+ return new Promise((resolve) => {
6
+ let timer;
7
+ const onNew = (name) => {
8
+ if (name !== event)
9
+ return;
10
+ done(true);
11
+ };
12
+ const done = (ok) => {
13
+ emitter.off('newListener', onNew);
14
+ if (timer)
15
+ clearTimeout(timer);
16
+ // 'newListener' fires just before the listener is added: settle on the
17
+ // next turn so an emit made after awaiting this reaches it.
18
+ setImmediate(() => resolve(ok));
19
+ };
20
+ timer = setTimeout(() => done(false), timeoutMs);
21
+ emitter.on('newListener', onNew);
22
+ });
23
+ }
24
+ /** The Ink screen is ready for start-up output: it takes lines, cards and questions. */
25
+ export async function whenInkReady(chunkEmitter, timeoutMs = 3000) {
26
+ const { cardSlotEmitter } = await import('./card-slot.js');
27
+ const { askQuestionEmitter } = await import('./ask-question-emitter.js');
28
+ const ready = await Promise.all([
29
+ whenListening(chunkEmitter, 'chunk', timeoutMs),
30
+ whenListening(cardSlotEmitter, 'confirm', timeoutMs),
31
+ whenListening(askQuestionEmitter, 'request', timeoutMs),
32
+ ]);
33
+ return ready.every(Boolean);
34
+ }
@@ -24,6 +24,21 @@ export function filterFileItems(items, query) {
24
24
  function fit(text, cells) {
25
25
  return truncateToWidth(sanitizeControlChars(text), Math.max(1, cells));
26
26
  }
27
+ /** A folder path keeps its END when cut (the project and .cspeach part): `…` + tail. */
28
+ function fitTail(text, cells) {
29
+ const clean = sanitizeControlChars(text);
30
+ const room = Math.max(1, cells);
31
+ if (stringWidth(clean) <= room)
32
+ return clean;
33
+ const chars = [...clean];
34
+ let tail = '';
35
+ for (let i = chars.length - 1; i >= 0; i--) {
36
+ if (stringWidth(chars[i] + tail) > room - 1)
37
+ break;
38
+ tail = chars[i] + tail;
39
+ }
40
+ return `…${tail}`;
41
+ }
27
42
  export function FilesCard({ request, width, onResolve }) {
28
43
  // Same raw-stdin reclaim as every card (an earlier inquirer prompt can leave
29
44
  // Ink's raw-mode bookkeeping stale).
@@ -83,9 +98,12 @@ export function FilesCard({ request, width, onResolve }) {
83
98
  const inner = Math.max(1, width - 4);
84
99
  const dim = roleProps('dim');
85
100
  const searchLabel = 'Search ';
101
+ const folderLabel = 'Folder ';
86
102
  // A long query is cut by cells (with …) so the row never wraps.
87
103
  const queryShown = fit(query, inner - stringWidth(searchLabel) - 1);
88
- return (_jsxs(CardFrame, { tag: "QUESTION", footnote: "/files", width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: fit(request.title, inner) }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...dim, children: searchLabel }), _jsx(Text, { children: queryShown }), _jsx(Text, { ...roleProps('sap'), children: "_" })] }), visible.length === 0
104
+ return (_jsxs(CardFrame, { tag: "QUESTION", footnote: "/files", width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: fit(request.title, inner) }), request.folder
105
+ ? _jsx(Text, { wrap: "truncate-end", ...dim, children: folderLabel + fitTail(request.folder, inner - stringWidth(folderLabel)) })
106
+ : null, _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...dim, children: searchLabel }), _jsx(Text, { children: queryShown }), _jsx(Text, { ...roleProps('sap'), children: "_" })] }), visible.length === 0
89
107
  ? _jsx(Text, { ...dim, children: "No file matches" })
90
108
  : visible.map((it, i) => {
91
109
  const on = start + i === cursor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.53",
3
+ "version": "1.1.55",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",