@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.
- package/dist/agent/loop.js +90 -7
- package/dist/agent/tool-dispatch.js +11 -0
- package/dist/auth/auth-file.js +3 -0
- package/dist/auth/key-resolver.js +8 -3
- package/dist/auth/me.js +6 -2
- package/dist/cli.js +25 -0
- package/dist/commands/login.js +22 -4
- package/dist/commands/plan-reground.js +5 -0
- package/dist/commands/sap-link.js +14 -1
- package/dist/commands/team-build-help.js +19 -0
- package/dist/commands/team-build.js +97 -0
- package/dist/commands/team-plan.js +2 -2
- package/dist/commands/team-spec.js +107 -0
- package/dist/commands/team.js +111 -4
- package/dist/journey/chain.js +8 -0
- package/dist/journey/clean-core.js +2 -2
- package/dist/journey/rebuild.js +8 -0
- package/dist/journey/run.js +9 -0
- package/dist/lane/ready-spec.js +32 -0
- package/dist/licence/account-name.js +31 -0
- package/dist/licence/paths.js +5 -0
- package/dist/licence/seat-kind.js +110 -0
- package/dist/licence/seat-state.js +4 -0
- package/dist/licence/terminal-seat.js +43 -0
- package/dist/licence/turn-gate.js +4 -0
- package/dist/one-shot.js +32 -0
- package/dist/projects/answer-blockers.js +31 -7
- package/dist/projects/blocker-answers-note.js +1 -1
- package/dist/projects/save-command.js +37 -2
- package/dist/projects/saved-lines.js +19 -17
- package/dist/projects/spec-share.js +270 -0
- package/dist/projects/team-build-context.js +49 -0
- package/dist/register/picture.js +2 -2
- package/dist/register/store.js +19 -0
- package/dist/renderer/routing-line.js +11 -0
- package/dist/repl/builtin-commands.js +4 -3
- package/dist/repl/file-picker.js +52 -5
- package/dist/repl/mode-cycle.js +12 -0
- package/dist/repl/open-questions-gate.js +193 -0
- package/dist/repl/ready-phrase.js +108 -0
- package/dist/repl/startup-account.js +64 -1
- package/dist/repl/team-build-screen.js +124 -0
- package/dist/repl/team-build-start.js +179 -0
- package/dist/repl.js +227 -10
- package/dist/sap-link/policy.js +4 -0
- package/dist/sap-link/registry.js +9 -3
- package/dist/sap-link/server.js +4 -1
- package/dist/tools/_team-work-path.js +25 -1
- package/dist/tools/cap/sap-link-tool.js +2 -0
- package/dist/tools/filesystem/file-edit.js +2 -2
- package/dist/tools/filesystem/file-write.js +2 -2
- package/dist/tools/hand-off.js +16 -1
- package/dist/tools/project/spec_answers_record.js +62 -9
- package/dist/tools/project/team_findings.js +2 -2
- package/dist/tools/seat-gate.js +41 -0
- package/dist/ui/when-listening.js +34 -0
- package/dist/ui/widgets/files-card.js +19 -1
- 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,
|
|
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: ${
|
|
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,
|
|
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: ${
|
|
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 {
|
package/dist/tools/hand-off.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
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) }),
|
|
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;
|