@cspeach/cli 0.9.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notice rendering — the 'warn' / 'info' side-channel (battery B2 fix).
|
|
3
|
+
*
|
|
4
|
+
* Tool handlers emit user-facing notices on the SAME EventEmitter that
|
|
5
|
+
* carries 'chunk' stream data:
|
|
6
|
+
*
|
|
7
|
+
* ctx.chunkEmitter?.emit('warn', msg) — user-actionable notices
|
|
8
|
+
* (e.g. "ignoring transport=X,
|
|
9
|
+
* object locked in Y")
|
|
10
|
+
* ctx.chunkEmitter?.emit('info', msg) — dim, non-fatal diagnostics
|
|
11
|
+
* (e.g. "snapshot ✓", lookup
|
|
12
|
+
* fallback traces)
|
|
13
|
+
*
|
|
14
|
+
* Before this module existed those events were NO-OPS in the live REPL:
|
|
15
|
+
* the Ink Body only consumed 'chunk', and the classic/one-shot paths never
|
|
16
|
+
* put a chunkEmitter on the ToolContext at all. resolveWriteTransport's
|
|
17
|
+
* carefully-worded Rule 9 notices (D15/D20) were invisible.
|
|
18
|
+
*
|
|
19
|
+
* Two consumers, one formatter:
|
|
20
|
+
* - Ink: ui/body.tsx subscribes 'warn'/'info' and pushes
|
|
21
|
+
* formatNoticeLine(...) through the SAME chunk batcher, so
|
|
22
|
+
* ordering relative to surrounding chunks is preserved.
|
|
23
|
+
* - classic / one-shot: createClassicOutputEmitter() returns an emitter
|
|
24
|
+
* whose listeners write straight to stdout. repl.tsx
|
|
25
|
+
* (classic branch) and one-shot.ts put it on ctx.chunkEmitter.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately NOT wired for subagents (tools/subagent/agent_run.ts):
|
|
28
|
+
* their ctx.chunkEmitter stays undefined so child-turn notices remain
|
|
29
|
+
* muted — exactly the pre-existing contract pinned in agent_run.test.ts.
|
|
30
|
+
*/
|
|
31
|
+
import chalk from 'chalk';
|
|
32
|
+
import { EventEmitter } from 'node:events';
|
|
33
|
+
/**
|
|
34
|
+
* Format one notice as a single terminal line (trailing newline included).
|
|
35
|
+
* Multi-line messages are collapsed to one line — notices are glanceable
|
|
36
|
+
* margin notes, not documents; anything long belongs in the tool result.
|
|
37
|
+
*
|
|
38
|
+
* warn → yellow " ⚠ message"
|
|
39
|
+
* info → dim " · message"
|
|
40
|
+
*/
|
|
41
|
+
export function formatNoticeLine(level, message) {
|
|
42
|
+
const oneLine = message.replace(/\s*\r?\n\s*/g, ' ').trim();
|
|
43
|
+
if (level === 'warn')
|
|
44
|
+
return chalk.yellow(` ⚠ ${oneLine}`) + '\n';
|
|
45
|
+
return chalk.dim(` · ${oneLine}`) + '\n';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the output emitter for the NON-Ink paths (classic REPL, one-shot).
|
|
49
|
+
*
|
|
50
|
+
* Listeners:
|
|
51
|
+
* 'chunk' → written verbatim (already-formatted stream text — e.g. the
|
|
52
|
+
* verify-chain line emitted by write tools)
|
|
53
|
+
* 'warn' → formatNoticeLine('warn', …)
|
|
54
|
+
* 'info' → formatNoticeLine('info', …)
|
|
55
|
+
*
|
|
56
|
+
* The `write` sink defaults to process.stdout — injectable for tests.
|
|
57
|
+
*/
|
|
58
|
+
export function createClassicOutputEmitter(write = (s) => process.stdout.write(s)) {
|
|
59
|
+
const emitter = new EventEmitter();
|
|
60
|
+
emitter.on('chunk', (s) => write(s));
|
|
61
|
+
emitter.on('warn', (msg) => write(formatNoticeLine('warn', msg)));
|
|
62
|
+
emitter.on('info', (msg) => write(formatNoticeLine('info', msg)));
|
|
63
|
+
return emitter;
|
|
64
|
+
}
|
|
@@ -92,6 +92,10 @@ const MESSAGES = {
|
|
|
92
92
|
'Reserving a transport request…',
|
|
93
93
|
'Asking SAP for a fresh TR…',
|
|
94
94
|
],
|
|
95
|
+
sap_transport_for_object: [
|
|
96
|
+
'Checking who owns {object_name}…',
|
|
97
|
+
'Looking for an owning transport…',
|
|
98
|
+
],
|
|
95
99
|
sap_transport_list: [
|
|
96
100
|
'Listing open transports…',
|
|
97
101
|
'Finding modifiable TRs…',
|
|
@@ -156,6 +160,10 @@ const MESSAGES = {
|
|
|
156
160
|
'Publishing service binding…',
|
|
157
161
|
'Asking SAP to expose the OData endpoint…',
|
|
158
162
|
],
|
|
163
|
+
sap_service_binding_unpublish: [
|
|
164
|
+
'Unpublishing service binding…',
|
|
165
|
+
'Asking SAP to remove the OData endpoint…',
|
|
166
|
+
],
|
|
159
167
|
};
|
|
160
168
|
const FALLBACK_MESSAGES = [
|
|
161
169
|
'Working on it…',
|
|
@@ -40,7 +40,7 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
|
|
|
40
40
|
* direct stdout (which would corrupt the live Ink frame).
|
|
41
41
|
*/
|
|
42
42
|
export function formatStatusFooter(params) {
|
|
43
|
-
const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens } = params;
|
|
43
|
+
const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant } = params;
|
|
44
44
|
const shortSession = sessionId.slice(0, 8);
|
|
45
45
|
const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
|
|
46
46
|
const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
|
|
@@ -49,17 +49,27 @@ export function formatStatusFooter(params) {
|
|
|
49
49
|
const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
|
|
50
50
|
const costTurnStr = costThisTurn != null ? formatCost(costThisTurn) : '';
|
|
51
51
|
const costSessStr = costSession != null ? `session: ${formatCost(costSession)}` : '';
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
52
|
+
// Ink turn-receipt (see the variant doc on StatusFooterParams):
|
|
53
|
+
// turn-only facts — everything session-scoped lives in <StatusLine />.
|
|
54
|
+
const parts = variant === 'turn'
|
|
55
|
+
? [
|
|
56
|
+
chalk.dim(skillDisplay),
|
|
57
|
+
chalk.dim(turns),
|
|
58
|
+
chalk.dim(tokStr),
|
|
59
|
+
...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
|
|
60
|
+
...(elapsed ? [chalk.dim(elapsed)] : []),
|
|
61
|
+
]
|
|
62
|
+
: [
|
|
63
|
+
chalk.dim(`session ${shortSession}`),
|
|
64
|
+
chalk.dim(sapAlias),
|
|
65
|
+
chalk.dim(skillDisplay),
|
|
66
|
+
chalk.dim(turns),
|
|
67
|
+
chalk.dim(tokStr),
|
|
68
|
+
...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
|
|
69
|
+
...(cachedStr ? [chalk.dim(cachedStr)] : []),
|
|
70
|
+
...(costSessStr ? [chalk.dim(costSessStr)] : []),
|
|
71
|
+
...(elapsed ? [chalk.dim(elapsed)] : []),
|
|
72
|
+
];
|
|
63
73
|
const lines = ['', RULE, ' ' + parts.join(chalk.dim(' │ '))];
|
|
64
74
|
if (sessionInputTokens != null && sessionInputTokens >= LARGE_SESSION_THRESHOLD) {
|
|
65
75
|
lines.push(' ' + chalk.yellow(`⚠ session getting large (${sessionInputTokens.toLocaleString()} input tokens)`
|
|
@@ -48,24 +48,67 @@ const GENERIC_SUFFIXES = [
|
|
|
48
48
|
{ at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
|
|
49
49
|
{ at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check the VPN)' },
|
|
50
50
|
];
|
|
51
|
+
/**
|
|
52
|
+
* 2026-07-06 (audit-timeout) — the plan-chain audit's own liveness schedule.
|
|
53
|
+
*
|
|
54
|
+
* The audit is a PROXY model call (api.cspeach.dev over the internet), NOT a
|
|
55
|
+
* SAP tool call, so the generic schedule's "check the VPN" 5m copy is a WRONG
|
|
56
|
+
* diagnosis (the VPN is only for SAP). It is also now bounded: runPhaseAudit
|
|
57
|
+
* enforces a 120s hard deadline PER attempt × 3 attempts, so the whole audit
|
|
58
|
+
* can never exceed ~6m before it returns infra_failed — no infinite hang. This
|
|
59
|
+
* schedule reflects that reality honestly: past 2m the audit is necessarily in
|
|
60
|
+
* a retry (a single attempt hard-stops at 120s), and there is nothing to check
|
|
61
|
+
* — it either recovers on the next attempt or returns infra_failed. The
|
|
62
|
+
* per-attempt transition ("[audit] attempt N of 3 (previous timed out)") is
|
|
63
|
+
* printed separately by plan-chain via runPhaseAudit's onRetry hook.
|
|
64
|
+
*/
|
|
65
|
+
export const AUDIT_HEARTBEAT_THRESHOLDS = [
|
|
66
|
+
{ at: 3_000, label: '[audit] …running' },
|
|
67
|
+
{ at: 10_000, label: '[audit] …running (10s)' },
|
|
68
|
+
{ at: 30_000, label: '[audit] …running (30s)' },
|
|
69
|
+
{ at: 60_000, label: '[audit] …running (60s — still running, large evidence can take a while)' },
|
|
70
|
+
{ at: 120_000, label: '[audit] …running (2m — still running; large evidence can take a while · Ctrl+C aborts the turn)' },
|
|
71
|
+
{ at: 240_000, label: '[audit] …running (4m — a prior attempt timed out; retrying · Ctrl+C aborts the turn)' },
|
|
72
|
+
{ at: 300_000, label: '[audit] …running (5m — nearly at the retry ceiling; will stop and re-audit on resume if it can\'t finish)' },
|
|
73
|
+
];
|
|
51
74
|
export function startThinkingHeartbeat(opts) {
|
|
52
75
|
const startedAt = Date.now();
|
|
53
76
|
let nextIdx = 0;
|
|
54
77
|
let stopped = false;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
// Precedence: an explicit `thresholds` schedule (audit) wins over the
|
|
79
|
+
// `label` generic-suffix path, which wins over the default LLM schedule.
|
|
80
|
+
const THRESHOLDS_MS = opts?.thresholds
|
|
81
|
+
? opts.thresholds
|
|
82
|
+
: opts?.label
|
|
83
|
+
? GENERIC_SUFFIXES.map((t) => ({ at: t.at, label: `${opts.label}${t.suffix}` }))
|
|
84
|
+
: LLM_THRESHOLDS;
|
|
85
|
+
/**
|
|
86
|
+
* Receives the styled label WITHOUT a newline. Fable review (2026-07-05,
|
|
87
|
+
* audit-reverify item 1): the newline used to be baked INSIDE the chalk
|
|
88
|
+
* argument — with color enabled (FORCE_COLOR / real terminals) chalk
|
|
89
|
+
* appends its closing style codes AFTER the embedded `\n`, so the sink
|
|
90
|
+
* branch's trailing-`\n` strip was a no-op and every tick rendered an
|
|
91
|
+
* extra blank line. The label is now styled alone and each branch owns
|
|
92
|
+
* its own termination: sink = none (the caller terminates), chunkEmitter /
|
|
93
|
+
* stdout = a plain `\n` appended after the styled text (byte-identical to
|
|
94
|
+
* before under no-color; under color the closing style code now correctly
|
|
95
|
+
* precedes the newline).
|
|
96
|
+
*/
|
|
58
97
|
const emitLine = (line) => {
|
|
59
|
-
if (opts?.
|
|
98
|
+
if (opts?.emit) {
|
|
99
|
+
// Custom sink (e.g. plan-chain's args.log) owns line termination.
|
|
100
|
+
opts.emit(line);
|
|
101
|
+
}
|
|
102
|
+
else if (opts?.chunkEmitter) {
|
|
60
103
|
// Phase A #4 — route through Ink's chunk pipeline. The Body
|
|
61
104
|
// component's reducer treats this like any other model chunk and
|
|
62
105
|
// appends it as a node, so the heartbeat lives inside the React
|
|
63
106
|
// render tree and survives subsequent renders.
|
|
64
|
-
opts.chunkEmitter.emit('chunk', line);
|
|
107
|
+
opts.chunkEmitter.emit('chunk', line + '\n');
|
|
65
108
|
}
|
|
66
109
|
else {
|
|
67
110
|
// Classic mode — direct stdout, plain newline-terminated line.
|
|
68
|
-
process.stdout.write(line);
|
|
111
|
+
process.stdout.write(line + '\n');
|
|
69
112
|
}
|
|
70
113
|
};
|
|
71
114
|
const tick = () => {
|
|
@@ -74,8 +117,21 @@ export function startThinkingHeartbeat(opts) {
|
|
|
74
117
|
const elapsed = Date.now() - startedAt;
|
|
75
118
|
while (nextIdx < THRESHOLDS_MS.length && THRESHOLDS_MS[nextIdx].at <= elapsed) {
|
|
76
119
|
const t = THRESHOLDS_MS[nextIdx];
|
|
77
|
-
//
|
|
78
|
-
|
|
120
|
+
// audit-lean FIX #2 — a dynamic suffix (e.g. the live token count),
|
|
121
|
+
// evaluated NOW so a stall shows a different number from progress.
|
|
122
|
+
// Defensive: observability must never break the liveness tick.
|
|
123
|
+
let suffix = '';
|
|
124
|
+
if (opts?.suffix) {
|
|
125
|
+
try {
|
|
126
|
+
suffix = opts.suffix();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
suffix = '';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Indent by 2 spaces to align with tool-call rows. Style ONLY the
|
|
133
|
+
// label (+ suffix) — never a newline inside the chalk call (see emitLine).
|
|
134
|
+
emitLine(chalk.dim(` ${t.label}${suffix}`));
|
|
79
135
|
nextIdx++;
|
|
80
136
|
}
|
|
81
137
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo_set transcript block — UX Wave 2 follow-up (CC-style checklist).
|
|
3
|
+
*
|
|
4
|
+
* Owner feedback (2026-07): "it's not like we have in Claude Code — only
|
|
5
|
+
* shows the phase it's working on with an arrow." The turn-status strip
|
|
6
|
+
* (single ▶ row) and the Ctrl+T panel are both by-design; what was missing
|
|
7
|
+
* was Claude Code's behaviour of dropping the FULL checklist into the
|
|
8
|
+
* scrollback transcript on every todo_set, so the user sees the whole plan
|
|
9
|
+
* advance in place rather than a raw `⏺ todo_set({...})` JSON line.
|
|
10
|
+
*
|
|
11
|
+
* This module is the pure formatter for that block. todo.ts emits the
|
|
12
|
+
* returned string as a `chunk` on ctx.chunkEmitter after a successful set
|
|
13
|
+
* (routes to Ink Static scrollback AND the classic 'chunk'→stdout path);
|
|
14
|
+
* the raw ⏺/⎿ todo_set rows are widget-suppressed (tool-widget.ts) so the
|
|
15
|
+
* block is the sole visible representation — exactly mirroring ask_question.
|
|
16
|
+
*
|
|
17
|
+
* House style follows todo-panel.tsx glyphs precisely so the transcript
|
|
18
|
+
* block and the Ctrl+T panel read as the same list:
|
|
19
|
+
* ▣ completed (dim)
|
|
20
|
+
* ▶ in_progress (peach, bold)
|
|
21
|
+
* □ pending (default)
|
|
22
|
+
* Every row is truncate-end (Wave-1 rule — no soft-wrap), preceded by a
|
|
23
|
+
* slim `Tasks (done/total done)` header.
|
|
24
|
+
*/
|
|
25
|
+
import chalk from 'chalk';
|
|
26
|
+
import { PEACH } from './banners.js';
|
|
27
|
+
/** Max visible width of a row's TEXT (after the 2-char glyph+space prefix).
|
|
28
|
+
* 72-col total matches the Ctrl+T panel width; 70 leaves room for `▶ `. */
|
|
29
|
+
export const TODO_BLOCK_TEXT_MAX = 70;
|
|
30
|
+
function truncateEnd(s, max) {
|
|
31
|
+
return s.length > max ? s.substring(0, max - 1) + '…' : s;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Render the checklist as a scrollback block (leading + trailing newline for
|
|
35
|
+
* separation from surrounding transcript content). Returns ANSI-coloured
|
|
36
|
+
* text ready to emit as a `chunk`.
|
|
37
|
+
*/
|
|
38
|
+
export function formatTodoChecklistBlock(todos) {
|
|
39
|
+
const done = todos.filter((t) => t.status === 'completed').length;
|
|
40
|
+
const total = todos.length;
|
|
41
|
+
const header = chalk.dim(`Tasks (${done}/${total} done)`);
|
|
42
|
+
const rows = todos.map((t) => {
|
|
43
|
+
const text = truncateEnd(t.text, TODO_BLOCK_TEXT_MAX);
|
|
44
|
+
if (t.status === 'completed')
|
|
45
|
+
return chalk.dim(`▣ ${text}`);
|
|
46
|
+
if (t.status === 'in_progress')
|
|
47
|
+
return chalk.hex(PEACH).bold(`▶ ${text}`);
|
|
48
|
+
return `□ ${text}`;
|
|
49
|
+
});
|
|
50
|
+
return '\n' + [header, ...rows].join('\n') + '\n';
|
|
51
|
+
}
|
|
@@ -21,6 +21,31 @@ import { PEACH } from './banners.js';
|
|
|
21
21
|
* - No cyan, no rainbow, no boxes.
|
|
22
22
|
*/
|
|
23
23
|
const peach = (s) => chalk.hex(PEACH)(s);
|
|
24
|
+
/**
|
|
25
|
+
* Widget-representation de-clutter (2026-07): tools whose OWN rendering IS
|
|
26
|
+
* the visible representation, making the generic ⏺ dispatch line (with its
|
|
27
|
+
* raw JSON args) and the SUCCESS ⎿ result line pure scrollback noise. For
|
|
28
|
+
* these the ⏺ top line and the success ⎿ row are suppressed; ERROR ⎿ rows
|
|
29
|
+
* still render (the ✗ row is the only trace of a failed call). The agent
|
|
30
|
+
* loop also skips the tool spinner for these (it would orphan without its
|
|
31
|
+
* ⏺ line).
|
|
32
|
+
*
|
|
33
|
+
* Members:
|
|
34
|
+
* - ask_question: the Ink form / modal (or the classic console.log
|
|
35
|
+
* question text) is the representation. Classic mode is unaffected in
|
|
36
|
+
* the ways that matter — the v1 path prints its question via
|
|
37
|
+
* console.log and the v2 classic path prints its `form i/n · header`
|
|
38
|
+
* blocks, neither of which goes through these renderers.
|
|
39
|
+
* - todo_set: the handler emits a CC-style checklist block into the
|
|
40
|
+
* transcript (renderer/todo-block.ts) on every successful set — that
|
|
41
|
+
* block is the representation, so the raw `⏺ todo_set({"todos":[…])`
|
|
42
|
+
* line + ⎿ JSON result are suppressed. Owner feedback drove this: the
|
|
43
|
+
* transcript should show the full checklist, not a JSON tool line.
|
|
44
|
+
*/
|
|
45
|
+
const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set']);
|
|
46
|
+
export function isWidgetSuppressedTool(name) {
|
|
47
|
+
return WIDGET_SUPPRESSED_TOOLS.has(name);
|
|
48
|
+
}
|
|
24
49
|
const ARG_SUMMARY_MAX = 64;
|
|
25
50
|
// 2026-05-15 (bug 1): widened from 96 → 240 so a parsed ADT exception
|
|
26
51
|
// message ("CTS_WBO_API/047: Request S4HK903388 is not a local request"
|
|
@@ -55,13 +80,17 @@ export function clearActiveSpinner() {
|
|
|
55
80
|
}
|
|
56
81
|
export function renderToolCallTop(params) {
|
|
57
82
|
const { name, args, chunkEmitter } = params;
|
|
83
|
+
// Ask-form de-clutter: the form/modal IS the visible representation —
|
|
84
|
+
// no ⏺ dispatch line (with its raw JSON args) belongs in scrollback.
|
|
85
|
+
if (isWidgetSuppressedTool(name))
|
|
86
|
+
return;
|
|
58
87
|
const write = (s) => {
|
|
59
88
|
if (chunkEmitter)
|
|
60
89
|
chunkEmitter.emit('chunk', s);
|
|
61
90
|
else
|
|
62
91
|
process.stdout.write(s);
|
|
63
92
|
};
|
|
64
|
-
const summary =
|
|
93
|
+
const summary = summariseToolArgs(name, args);
|
|
65
94
|
const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
|
|
66
95
|
const line = `${peach('⏺')} ${chalk.bold(name)}${wrapped}`;
|
|
67
96
|
write('\n' + line + '\n');
|
|
@@ -136,7 +165,15 @@ function startSpinnerWithVerbs(params, verbs, defaultVerb) {
|
|
|
136
165
|
return { stop: stopFn };
|
|
137
166
|
}
|
|
138
167
|
export function renderToolCallBottom(params) {
|
|
139
|
-
const { durationMs, isError, resultSummary, chunkEmitter } = params;
|
|
168
|
+
const { durationMs, isError, resultSummary, chunkEmitter, name, args } = params;
|
|
169
|
+
// Ask-form de-clutter: paired with the top-line suppression — on SUCCESS
|
|
170
|
+
// the answer already came back through the form; a ⎿ row would be noise.
|
|
171
|
+
// ERRORS still render (review fix): an ask_form_invalid /
|
|
172
|
+
// ask_question_invalid round never mounts a form, so without the ✗ row
|
|
173
|
+
// the turn would stall silently with nothing in scrollback. D29 made the
|
|
174
|
+
// bottom row self-identifying (name + args), so no orphan risk.
|
|
175
|
+
if (name && isWidgetSuppressedTool(name) && !isError)
|
|
176
|
+
return;
|
|
140
177
|
const write = (s) => {
|
|
141
178
|
if (chunkEmitter)
|
|
142
179
|
chunkEmitter.emit('chunk', s);
|
|
@@ -144,15 +181,26 @@ export function renderToolCallBottom(params) {
|
|
|
144
181
|
process.stdout.write(s);
|
|
145
182
|
};
|
|
146
183
|
const icon = isError ? chalk.red('✗') : chalk.green('✓');
|
|
184
|
+
// Label: plain (not bold — the top line keeps visual primacy) name +
|
|
185
|
+
// dim arg summary, mirroring the top-line shape so orphaned rows still
|
|
186
|
+
// read as "which tool, on what object".
|
|
187
|
+
let label = '';
|
|
188
|
+
if (name) {
|
|
189
|
+
const summary = summariseToolArgs(name, args ?? {});
|
|
190
|
+
const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
|
|
191
|
+
label = ` ${name}${wrapped}`;
|
|
192
|
+
}
|
|
147
193
|
const detailRaw = resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
|
|
148
194
|
const detail = detailRaw
|
|
149
195
|
? (isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
|
|
150
196
|
: '';
|
|
151
197
|
const timing = chalk.dim(formatDuration(durationMs));
|
|
152
198
|
const sep = chalk.dim('·');
|
|
153
|
-
let line = ` ${peach('⎿')} ${icon}`;
|
|
199
|
+
let line = ` ${peach('⎿')} ${icon}${label}`;
|
|
154
200
|
if (detail)
|
|
155
201
|
line += ` ${detail} ${sep} ${timing}`;
|
|
202
|
+
else if (label)
|
|
203
|
+
line += ` ${sep} ${timing}`;
|
|
156
204
|
else
|
|
157
205
|
line += ` ${timing}`;
|
|
158
206
|
write(line + '\n');
|
|
@@ -162,8 +210,11 @@ export function renderToolCallBottom(params) {
|
|
|
162
210
|
* arguments. Per-tool aware: pulls LLM-supplied summary first, then the
|
|
163
211
|
* common (name, type, package) triple, then specific keys for known tools.
|
|
164
212
|
* Falls back to a truncated JSON dump.
|
|
213
|
+
*
|
|
214
|
+
* Exported (D29) so the bottom result row can self-identify with the same
|
|
215
|
+
* summary the top line used.
|
|
165
216
|
*/
|
|
166
|
-
function
|
|
217
|
+
export function summariseToolArgs(toolName, args) {
|
|
167
218
|
if (!args || Object.keys(args).length === 0)
|
|
168
219
|
return '';
|
|
169
220
|
const a = args;
|
package/dist/renderer/tty.js
CHANGED
|
@@ -25,6 +25,36 @@ export function setRenderingOverride(mode) {
|
|
|
25
25
|
export function getRenderingOverride() {
|
|
26
26
|
return renderingOverride;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* B5 (2026-06-11) — headless answer policy signal.
|
|
30
|
+
*
|
|
31
|
+
* "Headless" means NO human can answer a prompt: stdin will never deliver
|
|
32
|
+
* an answer, so any inquirer/readline wait blocks forever (battery S2 Pass A
|
|
33
|
+
* stalled on exactly this — defect D1). Consumers (ask_question, the save
|
|
34
|
+
* hook, request_approval) branch to auto-answer / fail-fast policies instead
|
|
35
|
+
* of prompting.
|
|
36
|
+
*
|
|
37
|
+
* Decision order:
|
|
38
|
+
* 1. Explicit override — runOneShot() sets `true` at startup because
|
|
39
|
+
* one-shot is a single non-interactive turn by design (CI parity:
|
|
40
|
+
* the same invocation must behave identically piped or from a
|
|
41
|
+
* terminal). Tests set/clear it directly.
|
|
42
|
+
* 2. Fallback — stdin is not a TTY. Covers piped/redirected invocations
|
|
43
|
+
* that never went through runOneShot. stdin (not stdout) is the right
|
|
44
|
+
* floor: prompts BLOCK on stdin; piping only stdout still leaves a
|
|
45
|
+
* human able to answer.
|
|
46
|
+
*/
|
|
47
|
+
let headlessOverride = null;
|
|
48
|
+
/** Set (or clear with null) the explicit headless override. */
|
|
49
|
+
export function setHeadlessOverride(v) {
|
|
50
|
+
headlessOverride = v;
|
|
51
|
+
}
|
|
52
|
+
/** True when no human can answer prompts — see setHeadlessOverride. */
|
|
53
|
+
export function isHeadless() {
|
|
54
|
+
if (headlessOverride !== null)
|
|
55
|
+
return headlessOverride;
|
|
56
|
+
return process.stdin.isTTY !== true;
|
|
57
|
+
}
|
|
28
58
|
/**
|
|
29
59
|
* True when the Ink UI frame should be mounted.
|
|
30
60
|
*
|
|
@@ -32,8 +62,17 @@ export function getRenderingOverride() {
|
|
|
32
62
|
* 1. If override === 'classic', never use Ink (explicit opt-out)
|
|
33
63
|
* 2. If override === 'ink', use Ink when a TTY is available; otherwise
|
|
34
64
|
* fall back — Ink cannot render on a pipe regardless of user preference
|
|
35
|
-
* 3. If override === 'auto' (default), use Ink when interactive TTY
|
|
36
|
-
* not one-shot + CI=unset
|
|
65
|
+
* 3. If override === 'auto' (default), use Ink when interactive TTY on
|
|
66
|
+
* BOTH stdout and stdin + not one-shot + CI=unset
|
|
67
|
+
*
|
|
68
|
+
* UX-5 review C1 (2026-06-12): the auto branch must also require
|
|
69
|
+
* `process.stdin.isTTY` — Ink mounts raw-mode key handling on stdin, and a
|
|
70
|
+
* piped stdin (`"/cost`n/exit" | cspeach`) cannot enter raw mode. Worse,
|
|
71
|
+
* mounting Ink there bypassed the classic loop's early-line buffer
|
|
72
|
+
* (UX-5 #5), so every piped line was dropped. Piped-stdin invocations now
|
|
73
|
+
* take the classic path even when stdout is a terminal. The explicit
|
|
74
|
+
* `ui.rendering = 'ink'` override (branch 2) deliberately keeps its
|
|
75
|
+
* stdout-only floor — that's the user's explicit choice.
|
|
37
76
|
*
|
|
38
77
|
* v0.3+: pipeline.ts and approvals/render.ts branch on this.
|
|
39
78
|
*/
|
|
@@ -42,8 +81,8 @@ export function shouldUseInk() {
|
|
|
42
81
|
return false;
|
|
43
82
|
if (renderingOverride === 'ink')
|
|
44
83
|
return isTTY();
|
|
45
|
-
// auto
|
|
46
|
-
return isTTY() && !isOneShot() && !process.env['CI'];
|
|
84
|
+
// auto — stdin must be a TTY too (raw-mode capable, no piped lines to drop)
|
|
85
|
+
return isTTY() && process.stdin.isTTY === true && !isOneShot() && !process.env['CI'];
|
|
47
86
|
}
|
|
48
87
|
/**
|
|
49
88
|
* Stub for v0.3 Windows ConPTY / CI TTY hinting.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify-chain line — one compact row per completed SAP write summarizing
|
|
3
|
+
* the Forge-Rule pipeline the tool actually ran (tour slide-7 alignment):
|
|
4
|
+
*
|
|
5
|
+
* ① snapshot ✓ ② written ✓ ③ syntax ✓ ④ activate pending
|
|
6
|
+
*
|
|
7
|
+
* HONESTY CONTRACT: every segment is derived from the tool's real result —
|
|
8
|
+
* never synthesized. States render truthfully:
|
|
9
|
+
* - snapshot 'new_object' → "① snapshot – (new object)" (genuine ADT 404 —
|
|
10
|
+
* nothing to save; NEVER used for other read failures)
|
|
11
|
+
* - snapshot 'source_read_failed' → "① snapshot – (no source read: <msg>)" —
|
|
12
|
+
* the pre-write read failed for a non-404 reason; the object may exist
|
|
13
|
+
* but there is no recovery copy. Hard-gate tools block instead of
|
|
14
|
+
* emitting this; it only appears on soft (best-effort) gates.
|
|
15
|
+
* - snapshot 'best_effort_failed' → "① snapshot – (skipped)" (write tools
|
|
16
|
+
* where the gate is soft, e.g. class includes)
|
|
17
|
+
* - syntax fail → "③ syntax ✗ (L12: MESSAGE …)" AND no ④ at all —
|
|
18
|
+
* activation is blocked, so we don't show a fake pending step
|
|
19
|
+
* - syntax 'not_checked' → "③ syntax – (checked at activation)" —
|
|
20
|
+
* sap_set_class_include has no standalone syntax gate
|
|
21
|
+
* - ④ is always 'pending', never '✓': activation is a SEPARATE tool call
|
|
22
|
+
* (sap_activate); these write tools never activate.
|
|
23
|
+
*
|
|
24
|
+
* Consumed by the write tools in tools/sap-write.ts, emitted on the
|
|
25
|
+
* 'chunk' channel so it lands between the ⏺ dispatch line and the ⎿
|
|
26
|
+
* result row in both renderers.
|
|
27
|
+
*/
|
|
28
|
+
import chalk from 'chalk';
|
|
29
|
+
const pass = (s) => `${s} ${chalk.green('✓')}`;
|
|
30
|
+
const fail = (s, detail) => `${s} ${chalk.red('✗')}${detail ? ' ' + chalk.red(`(${detail})`) : ''}`;
|
|
31
|
+
const skip = (s, why) => chalk.dim(`${s} – (${why})`);
|
|
32
|
+
const ERROR_DETAIL_MAX = 60;
|
|
33
|
+
const SNAPSHOT_DETAIL_MAX = 40;
|
|
34
|
+
const truncate = (s, max) => {
|
|
35
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
36
|
+
return flat.length > max ? flat.substring(0, max - 1) + '…' : flat;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Build the chain line (no trailing newline). Returns the full line
|
|
40
|
+
* including the two-space indent that aligns with the ⎿ result row.
|
|
41
|
+
*/
|
|
42
|
+
export function buildWriteChainLine(input) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
// ① snapshot
|
|
45
|
+
if (input.snapshot === 'taken')
|
|
46
|
+
parts.push(pass('① snapshot'));
|
|
47
|
+
else if (input.snapshot === 'new_object')
|
|
48
|
+
parts.push(skip('① snapshot', 'new object'));
|
|
49
|
+
else if (input.snapshot === 'source_read_failed') {
|
|
50
|
+
// Honest: the read failed — we do NOT know the object is new, and there
|
|
51
|
+
// is no recovery copy. Never render this as "(new object)".
|
|
52
|
+
const why = input.snapshotDetail
|
|
53
|
+
? `no source read: ${truncate(input.snapshotDetail, SNAPSHOT_DETAIL_MAX)}`
|
|
54
|
+
: 'no source read';
|
|
55
|
+
parts.push(skip('① snapshot', why));
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
parts.push(skip('① snapshot', 'skipped'));
|
|
59
|
+
// ② written
|
|
60
|
+
parts.push(input.written ? pass('② written') : fail('② written'));
|
|
61
|
+
// ③ syntax (+ ④ activate, which only exists when activation is possible)
|
|
62
|
+
if (input.syntax === 'not_checked') {
|
|
63
|
+
parts.push(skip('③ syntax', 'checked at activation'));
|
|
64
|
+
parts.push(chalk.dim('④ activate pending'));
|
|
65
|
+
}
|
|
66
|
+
else if (input.syntax.ok) {
|
|
67
|
+
parts.push(pass('③ syntax'));
|
|
68
|
+
parts.push(chalk.dim('④ activate pending'));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const first = (input.syntax.errors[0] ?? 'syntax errors').replace(/\s+/g, ' ').trim();
|
|
72
|
+
const detail = first.length > ERROR_DETAIL_MAX ? first.substring(0, ERROR_DETAIL_MAX - 1) + '…' : first;
|
|
73
|
+
parts.push(fail('③ syntax', detail));
|
|
74
|
+
// No ④ — activation is blocked; rendering a pending step would lie.
|
|
75
|
+
}
|
|
76
|
+
return ' ' + parts.join(' ');
|
|
77
|
+
}
|
package/dist/repl/at-picker.js
CHANGED
|
@@ -17,24 +17,75 @@
|
|
|
17
17
|
* without screen takeover, Enter when they want a full search-as-you-type
|
|
18
18
|
* picker. Same backing data, different UI.
|
|
19
19
|
*/
|
|
20
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
20
22
|
import { search } from '@inquirer/prompts';
|
|
21
23
|
import { withInquirer } from './inquirer-guard.js';
|
|
22
24
|
import { inquirerTheme } from './inquirer-theme.js';
|
|
23
|
-
import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
|
|
25
|
+
import { ensureWorkspaceSync, listProjectFilesSync, isAttachableFilename } from '../projects/workspace.js';
|
|
24
26
|
const CANCEL_VALUE = '__cspeach_cancel__';
|
|
25
27
|
/**
|
|
26
28
|
* 2026-06-06 (Ink picker parity): workspace listing + substring filter,
|
|
27
29
|
* exported so the Ink FilePalette shares the same data + seeding
|
|
28
30
|
* semantics as this classic inquirer picker.
|
|
31
|
+
*
|
|
32
|
+
* Bug #4 (2026-06-15): the listing is the UNION of
|
|
33
|
+
* - all workspace files (.cspeach.json envelopes + .txt/.md/.docx/.pdf), AND
|
|
34
|
+
* - top-level .txt/.md/.docx/.pdf files in the current working directory.
|
|
35
|
+
* A spec file dropped next to the project (the common case) lives in the
|
|
36
|
+
* cwd, never in .cspeach/, so without the cwd half the picker was empty.
|
|
37
|
+
*
|
|
38
|
+
* Deduped by filename — when a name exists in both, the workspace entry
|
|
39
|
+
* wins. Non-recursive over the cwd; the .cspeach subfolder and any
|
|
40
|
+
* dotfiles/dirs are skipped. SYNCHRONOUS by contract (the Ink palette and
|
|
41
|
+
* pendingAtTokenPrefix call it sync). `cwd` / `workspace` are injectable
|
|
42
|
+
* for tests; the zero-arg call resolves both from the environment.
|
|
29
43
|
*/
|
|
30
|
-
export function listWorkspaceFileChoices() {
|
|
44
|
+
export function listWorkspaceFileChoices(opts) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
// Workspace half (takes precedence on name collisions).
|
|
48
|
+
try {
|
|
49
|
+
const ws = opts?.workspace ?? ensureWorkspaceSync();
|
|
50
|
+
for (const f of listProjectFilesSync(ws)) {
|
|
51
|
+
if (seen.has(f.filename))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(f.filename);
|
|
54
|
+
out.push({ filename: f.filename, mtimeMs: f.mtimeMs });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// fall through — still return whatever the cwd half yields
|
|
59
|
+
}
|
|
60
|
+
// cwd half — top-level .txt/.md/.docx/.pdf only. Never throws.
|
|
61
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
31
62
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
63
|
+
for (const name of readdirSync(cwd)) {
|
|
64
|
+
if (name.startsWith('.'))
|
|
65
|
+
continue; // dotfiles/dirs (incl. .cspeach)
|
|
66
|
+
if (!isAttachableFilename(name))
|
|
67
|
+
continue;
|
|
68
|
+
if (seen.has(name))
|
|
69
|
+
continue; // workspace already has it
|
|
70
|
+
const full = join(cwd, name);
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = statSync(full);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!stat.isFile())
|
|
79
|
+
continue;
|
|
80
|
+
seen.add(name);
|
|
81
|
+
out.push({ filename: name, mtimeMs: stat.mtimeMs });
|
|
82
|
+
}
|
|
34
83
|
}
|
|
35
84
|
catch {
|
|
36
|
-
return
|
|
85
|
+
// unreadable cwd — return just the workspace list
|
|
37
86
|
}
|
|
87
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
88
|
+
return out;
|
|
38
89
|
}
|
|
39
90
|
/** Substring filter; empty prefix returns everything. */
|
|
40
91
|
export function filterFileChoices(prefix, all) {
|
|
@@ -126,8 +177,10 @@ export function pendingAtTokenPrefix(trimmed, knownFilenames) {
|
|
|
126
177
|
}
|
|
127
178
|
else {
|
|
128
179
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
// Bug #4: check the MERGED listing (workspace + cwd text files),
|
|
181
|
+
// not workspace-only, so an exact `@spec.txt` for a cwd file
|
|
182
|
+
// submits directly instead of re-opening the picker.
|
|
183
|
+
const files = listWorkspaceFileChoices();
|
|
131
184
|
const exact = files.some((f) => f.filename === prefix);
|
|
132
185
|
if (exact)
|
|
133
186
|
return null;
|