@cspeach/cli 0.6.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/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { PEACH } from './banners.js';
|
|
3
|
+
/**
|
|
4
|
+
* Tool-dispatch renderer — CC-style two-line format with peach spinner.
|
|
5
|
+
*
|
|
6
|
+
* ⏺ tool_name(arg-summary) ← printed at dispatch start
|
|
7
|
+
* ⠋ slicing peach… ← spinner, in place, while dispatch runs
|
|
8
|
+
* ⎿ ✓ result-summary · 1.2s ← overwrites the spinner on completion
|
|
9
|
+
*
|
|
10
|
+
* Top line (renderToolCallTop) prints at content_block_stop, before the
|
|
11
|
+
* dispatch fires. The spinner is started immediately after and runs until
|
|
12
|
+
* the bottom line (renderToolCallBottom) replaces it. The bottom line
|
|
13
|
+
* uses ANSI cursor-up + erase-line to consume the spinner row, leaving
|
|
14
|
+
* just the dispatch + result rows in scrollback.
|
|
15
|
+
*
|
|
16
|
+
* Color discipline:
|
|
17
|
+
* - Peach (PEACH) for the identity glyphs (⏺ dispatch, ⎿ result, spinner).
|
|
18
|
+
* - Default white/bold for the tool name.
|
|
19
|
+
* - Dim grey for arg-summary inside parens, timing, the spinner's verb.
|
|
20
|
+
* - Green (✓) on success, red (✗ + body) on error.
|
|
21
|
+
* - No cyan, no rainbow, no boxes.
|
|
22
|
+
*/
|
|
23
|
+
const peach = (s) => chalk.hex(PEACH)(s);
|
|
24
|
+
const ARG_SUMMARY_MAX = 64;
|
|
25
|
+
const RESULT_SUMMARY_MAX = 96;
|
|
26
|
+
/** Braille spinner — 8 frames at 80ms each gives a smooth rotation that
|
|
27
|
+
* reads as "still working" without being distracting. */
|
|
28
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
29
|
+
const SPINNER_TICK_MS = 80;
|
|
30
|
+
/** Vibe verbs — picked once per dispatch so the indicator feels alive
|
|
31
|
+
* without thrashing. Default is `slicing peach` per the brand bit. */
|
|
32
|
+
const SPINNER_VERBS = ['slicing peach', 'mashing pulp', 'pitting', 'pressing', 'simmering', 'plating'];
|
|
33
|
+
/** LLM-think verbs — different pool from tool-dispatch so the user can
|
|
34
|
+
* tell at a glance whether we're waiting on the model or running a
|
|
35
|
+
* tool. Tool dispatch = peach-cooking metaphor; thinking = peach-growing
|
|
36
|
+
* metaphor (a beat slower in pacing). */
|
|
37
|
+
const THINKING_VERBS = ['thinking', 'ripening', 'steeping', 'marinating', 'pondering', 'considering'];
|
|
38
|
+
/**
|
|
39
|
+
* Module-level reference to the currently-running spinner's stop function.
|
|
40
|
+
* Set by startToolSpinner; cleared on stop. Lets unrelated subsystems
|
|
41
|
+
* (e.g. inquirer-guard, when an inquirer prompt is about to open) silence
|
|
42
|
+
* the spinner so it doesn't fight inquirer for stdout/cursor.
|
|
43
|
+
*/
|
|
44
|
+
let activeSpinnerStop = null;
|
|
45
|
+
export function clearActiveSpinner() {
|
|
46
|
+
if (activeSpinnerStop) {
|
|
47
|
+
activeSpinnerStop();
|
|
48
|
+
activeSpinnerStop = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function renderToolCallTop(params) {
|
|
52
|
+
const { name, args, chunkEmitter } = params;
|
|
53
|
+
const write = (s) => {
|
|
54
|
+
if (chunkEmitter)
|
|
55
|
+
chunkEmitter.emit('chunk', s);
|
|
56
|
+
else
|
|
57
|
+
process.stdout.write(s);
|
|
58
|
+
};
|
|
59
|
+
const summary = argSummary(name, args);
|
|
60
|
+
const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
|
|
61
|
+
const line = `${peach('⏺')} ${chalk.bold(name)}${wrapped}`;
|
|
62
|
+
write('\n' + line + '\n');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Animated peach-themed indicator while we wait on the LLM (between
|
|
66
|
+
* `provider.createStream` and the first content event). Same visual
|
|
67
|
+
* shape as the tool spinner but pulls from THINKING_VERBS so the user
|
|
68
|
+
* can distinguish "model is thinking" from "tool is running" at a
|
|
69
|
+
* glance. Wired from agent/loop.ts immediately before the await on
|
|
70
|
+
* createStream; stopped on the first content_block_start (or on
|
|
71
|
+
* error). Also stopped by clearActiveSpinner (e.g. when an inquirer
|
|
72
|
+
* prompt opens, same as tool spinner).
|
|
73
|
+
*/
|
|
74
|
+
export function startThinkingSpinner(params) {
|
|
75
|
+
return startSpinnerWithVerbs(params, THINKING_VERBS, 'thinking');
|
|
76
|
+
}
|
|
77
|
+
export function startToolSpinner(params) {
|
|
78
|
+
return startSpinnerWithVerbs(params, SPINNER_VERBS, 'slicing peach');
|
|
79
|
+
}
|
|
80
|
+
function startSpinnerWithVerbs(params, verbs, defaultVerb) {
|
|
81
|
+
const { chunkEmitter } = params;
|
|
82
|
+
const disabled = chunkEmitter !== undefined ||
|
|
83
|
+
!process.stdout.isTTY ||
|
|
84
|
+
process.env['CI'] === '1' ||
|
|
85
|
+
process.env['TERM'] === 'dumb';
|
|
86
|
+
if (disabled)
|
|
87
|
+
return { stop: () => undefined };
|
|
88
|
+
// Pick one verb per dispatch so the indicator doesn't churn.
|
|
89
|
+
const verb = verbs[Math.floor(Math.random() * verbs.length)] ?? defaultVerb;
|
|
90
|
+
let frameIdx = 0;
|
|
91
|
+
let stopped = false;
|
|
92
|
+
const paint = () => {
|
|
93
|
+
if (stopped)
|
|
94
|
+
return;
|
|
95
|
+
const frame = SPINNER_FRAMES[frameIdx];
|
|
96
|
+
frameIdx = (frameIdx + 1) % SPINNER_FRAMES.length;
|
|
97
|
+
// Erase current line + reprint. First call appends a fresh line; later
|
|
98
|
+
// calls cursor-up to the spinner row and overwrite in place.
|
|
99
|
+
const line = ` ${peach(frame)} ${chalk.dim(verb + '…')}`;
|
|
100
|
+
process.stdout.write(`\x1B[2K\r${line}`);
|
|
101
|
+
};
|
|
102
|
+
// Hide the terminal cursor while the spinner animates so it doesn't blink
|
|
103
|
+
// mid-frame and confuse the user about whether their typing went through.
|
|
104
|
+
process.stdout.write('\x1B[?25l');
|
|
105
|
+
// Print the initial spinner line.
|
|
106
|
+
process.stdout.write('\r');
|
|
107
|
+
paint();
|
|
108
|
+
const interval = setInterval(paint, SPINNER_TICK_MS);
|
|
109
|
+
interval.unref?.();
|
|
110
|
+
const stopFn = () => {
|
|
111
|
+
if (stopped)
|
|
112
|
+
return;
|
|
113
|
+
stopped = true;
|
|
114
|
+
clearInterval(interval);
|
|
115
|
+
// Erase the spinner line so the result row paints in its place.
|
|
116
|
+
// \r → start of line, \x1B[2K → erase entire line. No newline so the
|
|
117
|
+
// bottom renderer's \n keeps the spacing correct.
|
|
118
|
+
// Also re-show the cursor (\x1B[?25h) — paired with the hide above.
|
|
119
|
+
process.stdout.write('\r\x1B[2K\x1B[?25h');
|
|
120
|
+
// Clear the global handle if it's still pointing at us. (If a newer
|
|
121
|
+
// spinner already replaced us, leave its registration alone.)
|
|
122
|
+
if (activeSpinnerStop === stopFn)
|
|
123
|
+
activeSpinnerStop = null;
|
|
124
|
+
};
|
|
125
|
+
// Defensive: if a previous spinner is somehow still active when we start
|
|
126
|
+
// (shouldn't happen — agent loop stops the previous one before next
|
|
127
|
+
// dispatch — but easy to guarantee here), stop it before claiming the slot.
|
|
128
|
+
if (activeSpinnerStop)
|
|
129
|
+
activeSpinnerStop();
|
|
130
|
+
activeSpinnerStop = stopFn;
|
|
131
|
+
return { stop: stopFn };
|
|
132
|
+
}
|
|
133
|
+
export function renderToolCallBottom(params) {
|
|
134
|
+
const { durationMs, isError, resultSummary, chunkEmitter } = params;
|
|
135
|
+
const write = (s) => {
|
|
136
|
+
if (chunkEmitter)
|
|
137
|
+
chunkEmitter.emit('chunk', s);
|
|
138
|
+
else
|
|
139
|
+
process.stdout.write(s);
|
|
140
|
+
};
|
|
141
|
+
const icon = isError ? chalk.red('✗') : chalk.green('✓');
|
|
142
|
+
const detailRaw = resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
|
|
143
|
+
const detail = detailRaw
|
|
144
|
+
? (isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
|
|
145
|
+
: '';
|
|
146
|
+
const timing = chalk.dim(formatDuration(durationMs));
|
|
147
|
+
const sep = chalk.dim('·');
|
|
148
|
+
let line = ` ${peach('⎿')} ${icon}`;
|
|
149
|
+
if (detail)
|
|
150
|
+
line += ` ${detail} ${sep} ${timing}`;
|
|
151
|
+
else
|
|
152
|
+
line += ` ${timing}`;
|
|
153
|
+
write(line + '\n');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Compact one-liner describing the most informative parts of the dispatch
|
|
157
|
+
* arguments. Per-tool aware: pulls LLM-supplied summary first, then the
|
|
158
|
+
* common (name, type, package) triple, then specific keys for known tools.
|
|
159
|
+
* Falls back to a truncated JSON dump.
|
|
160
|
+
*/
|
|
161
|
+
function argSummary(toolName, args) {
|
|
162
|
+
if (!args || Object.keys(args).length === 0)
|
|
163
|
+
return '';
|
|
164
|
+
const a = args;
|
|
165
|
+
// request_approval / ask_question — surface the LLM-supplied summary text.
|
|
166
|
+
if (typeof a['summary'] === 'string') {
|
|
167
|
+
return truncate(a['summary'], ARG_SUMMARY_MAX);
|
|
168
|
+
}
|
|
169
|
+
if (typeof a['question'] === 'string') {
|
|
170
|
+
return truncate(a['question'], ARG_SUMMARY_MAX);
|
|
171
|
+
}
|
|
172
|
+
// Class.method shorthand for sap_update_method.
|
|
173
|
+
if (typeof a['class_name'] === 'string' && typeof a['method_name'] === 'string') {
|
|
174
|
+
return truncate(`${a['class_name']}.${a['method_name']}`, ARG_SUMMARY_MAX);
|
|
175
|
+
}
|
|
176
|
+
// Common (name, type, package) for SAP object tools.
|
|
177
|
+
const parts = [];
|
|
178
|
+
if (typeof a['name'] === 'string')
|
|
179
|
+
parts.push(a['name']);
|
|
180
|
+
if (typeof a['type'] === 'string' && a['type'] !== a['name'])
|
|
181
|
+
parts.push(a['type']);
|
|
182
|
+
if (typeof a['package'] === 'string')
|
|
183
|
+
parts.push(`in ${a['package']}`);
|
|
184
|
+
if (parts.length > 0)
|
|
185
|
+
return truncate(parts.join(' '), ARG_SUMMARY_MAX);
|
|
186
|
+
// Search query / SQL.
|
|
187
|
+
if (typeof a['query'] === 'string')
|
|
188
|
+
return truncate(a['query'], ARG_SUMMARY_MAX);
|
|
189
|
+
// Fallback — short JSON.
|
|
190
|
+
return truncate(JSON.stringify(args), ARG_SUMMARY_MAX);
|
|
191
|
+
}
|
|
192
|
+
function truncate(s, max) {
|
|
193
|
+
return s.length > max ? s.substring(0, max - 1) + '…' : s;
|
|
194
|
+
}
|
|
195
|
+
function formatDuration(ms) {
|
|
196
|
+
if (ms < 1000)
|
|
197
|
+
return `${ms}ms`;
|
|
198
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
199
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// C:\Users\Admin\ClaudeProjects\ABAPForge\cspeach-cli\src\renderer\tty.ts
|
|
2
|
+
/**
|
|
3
|
+
* TTY / mode detection helpers.
|
|
4
|
+
*/
|
|
5
|
+
import * as os from 'node:os';
|
|
6
|
+
/** True when stdout is an interactive terminal (not piped / redirected). */
|
|
7
|
+
export function isTTY() {
|
|
8
|
+
return process.stdout.isTTY === true;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* True when the CLI was invoked with a positional argument (one-shot mode)
|
|
12
|
+
* or with the --one-shot flag.
|
|
13
|
+
* e.g. `cspeach "/radar ZTTT_GAME"` vs interactive REPL.
|
|
14
|
+
*/
|
|
15
|
+
export function isOneShot() {
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
return args.some((a) => a === '--one-shot') || args.some((a) => !a.startsWith('-'));
|
|
18
|
+
}
|
|
19
|
+
let renderingOverride = 'classic';
|
|
20
|
+
/** Set the rendering mode override. Call once at startup after loadConfig(). */
|
|
21
|
+
export function setRenderingOverride(mode) {
|
|
22
|
+
renderingOverride = mode;
|
|
23
|
+
}
|
|
24
|
+
/** Get the current rendering override (for debugging / status display). */
|
|
25
|
+
export function getRenderingOverride() {
|
|
26
|
+
return renderingOverride;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* True when the Ink UI frame should be mounted.
|
|
30
|
+
*
|
|
31
|
+
* Decision order:
|
|
32
|
+
* 1. If override === 'classic', never use Ink (explicit opt-out)
|
|
33
|
+
* 2. If override === 'ink', use Ink when a TTY is available; otherwise
|
|
34
|
+
* 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
|
|
37
|
+
*
|
|
38
|
+
* v0.3+: pipeline.ts and approvals/render.ts branch on this.
|
|
39
|
+
*/
|
|
40
|
+
export function shouldUseInk() {
|
|
41
|
+
if (renderingOverride === 'classic')
|
|
42
|
+
return false;
|
|
43
|
+
if (renderingOverride === 'ink')
|
|
44
|
+
return isTTY();
|
|
45
|
+
// auto
|
|
46
|
+
return isTTY() && !isOneShot() && !process.env['CI'];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Stub for v0.3 Windows ConPTY / CI TTY hinting.
|
|
50
|
+
*
|
|
51
|
+
* On Windows, ConPTY sets WT_SESSION; GitHub Actions sets CI=true.
|
|
52
|
+
* Returns undefined when no hint is available.
|
|
53
|
+
*/
|
|
54
|
+
export function ciTtyHint() {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/** True when running on Windows without a capable terminal (no WT or iTerm/wezterm). */
|
|
58
|
+
export function isWindowsLegacyTerminal() {
|
|
59
|
+
return (os.platform() === 'win32' &&
|
|
60
|
+
!process.env['WT_SESSION'] &&
|
|
61
|
+
!process.env['TERM_PROGRAM']);
|
|
62
|
+
}
|
|
63
|
+
/** Use full Unicode box-drawing chars only when NOT on a legacy Windows terminal. */
|
|
64
|
+
export function shouldUseUnicodeBoxChars() {
|
|
65
|
+
return !isWindowsLegacyTerminal();
|
|
66
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const OPEN_RE = /^```cspeach-widget:(\S+)\s*$/;
|
|
2
|
+
// CLOSE_RE matches a bare ``` on a line — which means a widget payload
|
|
3
|
+
// containing ``` (e.g. a diff-viewer unified diff inside JSON strings) would
|
|
4
|
+
// prematurely close the fence. LLMs emit widget JSON as single-line payloads
|
|
5
|
+
// per the widget-emit contract (spec §4.3) — if this invariant breaks, the
|
|
6
|
+
// renderer will need a more sophisticated parser (stack-based with string-
|
|
7
|
+
// literal awareness inside JSON).
|
|
8
|
+
const CLOSE_RE = /^```\s*$/;
|
|
9
|
+
const SENTINEL = '<!-- widget-real -->';
|
|
10
|
+
export function extractWidgets(text) {
|
|
11
|
+
const segments = [];
|
|
12
|
+
let pos = 0;
|
|
13
|
+
let i = 0;
|
|
14
|
+
// Index of first line NOT yet emitted as passthrough. Any line between this
|
|
15
|
+
// index and the current position that isn't a widget-open needs to surface as
|
|
16
|
+
// passthrough so the pipeline can still render non-widget markdown.
|
|
17
|
+
let passStart = 0;
|
|
18
|
+
const lines = text.split('\n');
|
|
19
|
+
const flushPassthrough = (upToExclusive) => {
|
|
20
|
+
if (passStart >= upToExclusive)
|
|
21
|
+
return;
|
|
22
|
+
// Reassemble the raw text for these lines. We lost the original line-break
|
|
23
|
+
// info when we split, but rebuilding with `\n` between lines and a trailing
|
|
24
|
+
// `\n` if the region extends to end-of-input matches how renderChunk's
|
|
25
|
+
// passthrough helper already expects its input.
|
|
26
|
+
const slice = lines.slice(passStart, upToExclusive);
|
|
27
|
+
const tail = upToExclusive < lines.length ? '\n' : '';
|
|
28
|
+
const rawText = slice.join('\n') + tail;
|
|
29
|
+
if (rawText.length === 0) {
|
|
30
|
+
passStart = upToExclusive;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const spanStart = pos;
|
|
34
|
+
const spanEnd = spanStart + rawText.length;
|
|
35
|
+
pos += rawText.length;
|
|
36
|
+
segments.push({ type: 'passthrough', rawText, span: [spanStart, spanEnd] });
|
|
37
|
+
passStart = upToExclusive;
|
|
38
|
+
};
|
|
39
|
+
while (i < lines.length) {
|
|
40
|
+
const m = OPEN_RE.exec(lines[i]);
|
|
41
|
+
if (!m) {
|
|
42
|
+
i++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// Found opening fence — first flush any preceding passthrough region.
|
|
46
|
+
flushPassthrough(i);
|
|
47
|
+
const openLine = i;
|
|
48
|
+
const widgetType = m[1];
|
|
49
|
+
const bodyLines = [];
|
|
50
|
+
i++;
|
|
51
|
+
let closed = false;
|
|
52
|
+
while (i < lines.length) {
|
|
53
|
+
if (CLOSE_RE.test(lines[i])) {
|
|
54
|
+
closed = true;
|
|
55
|
+
i++;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
bodyLines.push(lines[i]);
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
if (!closed) {
|
|
62
|
+
// Stream-incomplete: return buffered sentinel so caller waits.
|
|
63
|
+
return [...segments, { type: 'buffered' }];
|
|
64
|
+
}
|
|
65
|
+
// Compute raw span in original string (approximate by line offset).
|
|
66
|
+
const spanStart = pos;
|
|
67
|
+
const blockText = lines.slice(openLine, i).join('\n') + '\n';
|
|
68
|
+
const spanEnd = spanStart + blockText.length;
|
|
69
|
+
pos += blockText.length;
|
|
70
|
+
passStart = i;
|
|
71
|
+
// Sentinel check — compare on trimmed values so leading/trailing whitespace
|
|
72
|
+
// around `<!-- widget-real -->` is tolerated.
|
|
73
|
+
const firstContent = bodyLines.find((l) => l.trim() !== '');
|
|
74
|
+
if (firstContent?.trim() !== SENTINEL) {
|
|
75
|
+
segments.push({ type: 'passthrough', rawText: blockText, span: [spanStart, spanEnd] });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Strip the sentinel line AND any empty lines before the JSON (again, trim-aware).
|
|
79
|
+
const jsonLines = bodyLines.filter((l) => l.trim() !== '' && l.trim() !== SENTINEL);
|
|
80
|
+
const rawJson = jsonLines.join('\n');
|
|
81
|
+
segments.push({ type: 'widget', widgetType, rawJson, span: [spanStart, spanEnd] });
|
|
82
|
+
}
|
|
83
|
+
// Flush trailing passthrough (lines after the last widget or the entire input
|
|
84
|
+
// if no widget fence was ever opened).
|
|
85
|
+
flushPassthrough(lines.length);
|
|
86
|
+
return segments;
|
|
87
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
// Inner content width — the box becomes content+4 total width.
|
|
3
|
+
const CONTENT_WIDTH = 55;
|
|
4
|
+
const TOTAL_WIDTH = CONTENT_WIDTH + 4; // left "┃ " + content + " ┃"
|
|
5
|
+
function padLine(s) {
|
|
6
|
+
// Pad to CONTENT_WIDTH visible chars, preserve ANSI codes if any
|
|
7
|
+
const ANSI = /\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl]/g;
|
|
8
|
+
const visible = s.replace(ANSI, '');
|
|
9
|
+
const padding = Math.max(0, CONTENT_WIDTH - visible.length);
|
|
10
|
+
return s + ' '.repeat(padding);
|
|
11
|
+
}
|
|
12
|
+
function wrap(text, width) {
|
|
13
|
+
if (text.length <= width)
|
|
14
|
+
return [text];
|
|
15
|
+
const words = text.split(/\s+/);
|
|
16
|
+
const lines = [];
|
|
17
|
+
let current = '';
|
|
18
|
+
for (const word of words) {
|
|
19
|
+
if (current.length === 0) {
|
|
20
|
+
current = word;
|
|
21
|
+
}
|
|
22
|
+
else if (current.length + 1 + word.length <= width) {
|
|
23
|
+
current += ' ' + word;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
lines.push(current);
|
|
27
|
+
current = word;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (current.length > 0)
|
|
31
|
+
lines.push(current);
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Render a question widget as a heavy-bordered chalk card for classic-mode
|
|
36
|
+
* REPL output. Called by the pipeline's widget-fallback path when
|
|
37
|
+
* shouldUseInk() is false OR when we're rendering the default text
|
|
38
|
+
* fallback for the widget system.
|
|
39
|
+
*
|
|
40
|
+
* Uses heavy box-drawing chars (┏ ┓ ┗ ┛ ━ ┃) so the question card
|
|
41
|
+
* visually distinguishes from the light-bordered tool-widget boxes.
|
|
42
|
+
*/
|
|
43
|
+
export function renderQuestionCard(widget) {
|
|
44
|
+
const border = chalk.cyan;
|
|
45
|
+
const dim = chalk.dim;
|
|
46
|
+
const top = border('┏━ ' + chalk.cyan.bold(`Question: ${widget.id}`) + ' ' + '━'.repeat(Math.max(0, CONTENT_WIDTH - widget.id.length - 12)) + '━┓');
|
|
47
|
+
const bottom = border('┗' + '━'.repeat(TOTAL_WIDTH - 2) + '┛');
|
|
48
|
+
const lines = [top];
|
|
49
|
+
lines.push(border('┃ ') + padLine('') + border(' ┃'));
|
|
50
|
+
// Question text (wrapped)
|
|
51
|
+
for (const line of wrap(widget.question, CONTENT_WIDTH)) {
|
|
52
|
+
lines.push(border('┃ ') + padLine(chalk.bold(line)) + border(' ┃'));
|
|
53
|
+
}
|
|
54
|
+
// Optional context
|
|
55
|
+
if (widget.context) {
|
|
56
|
+
lines.push(border('┃ ') + padLine('') + border(' ┃'));
|
|
57
|
+
for (const line of wrap(`Context: ${widget.context}`, CONTENT_WIDTH)) {
|
|
58
|
+
lines.push(border('┃ ') + padLine(dim(line)) + border(' ┃'));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Choice list for choice/multi kinds
|
|
62
|
+
if (widget.kind === 'choice' || widget.kind === 'multi') {
|
|
63
|
+
lines.push(border('┃ ') + padLine('') + border(' ┃'));
|
|
64
|
+
const header = widget.kind === 'choice' ? 'Pick one:' : 'Pick any (comma-separate):';
|
|
65
|
+
lines.push(border('┃ ') + padLine(chalk.bold(header)) + border(' ┃'));
|
|
66
|
+
const choices = widget.choices ?? [];
|
|
67
|
+
for (let i = 0; i < choices.length; i++) {
|
|
68
|
+
const choice = choices[i];
|
|
69
|
+
const line = `[${i + 1}] ${choice.label}`;
|
|
70
|
+
for (const wrapped of wrap(line, CONTENT_WIDTH)) {
|
|
71
|
+
lines.push(border('┃ ') + padLine(wrapped) + border(' ┃'));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
lines.push(border('┃ ') + padLine('') + border(' ┃'));
|
|
76
|
+
lines.push(bottom);
|
|
77
|
+
return '\n' + lines.join('\n') + '\n';
|
|
78
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const depGraphSchema = z.object({
|
|
3
|
+
root: z.string(),
|
|
4
|
+
children: z.array(z.object({
|
|
5
|
+
name: z.string(),
|
|
6
|
+
type: z.string(),
|
|
7
|
+
relation: z.string(),
|
|
8
|
+
})),
|
|
9
|
+
});
|
|
10
|
+
export const barChartSchema = z.array(z.object({
|
|
11
|
+
label: z.string(),
|
|
12
|
+
value: z.number(),
|
|
13
|
+
color: z.string().optional(),
|
|
14
|
+
}));
|
|
15
|
+
export const stackFramesSchema = z.array(z.object({
|
|
16
|
+
frame: z.number(),
|
|
17
|
+
program: z.string(),
|
|
18
|
+
line: z.number(),
|
|
19
|
+
code: z.string(),
|
|
20
|
+
type: z.enum(['error', 'context']),
|
|
21
|
+
}));
|
|
22
|
+
export const diffSchema = z.object({ unified: z.string() });
|
|
23
|
+
export const questionSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
question: z.string(),
|
|
27
|
+
context: z.string().optional(),
|
|
28
|
+
kind: z.enum(['text', 'choice', 'multi']),
|
|
29
|
+
choices: z
|
|
30
|
+
.array(z.object({
|
|
31
|
+
value: z.string(),
|
|
32
|
+
label: z.string(),
|
|
33
|
+
}))
|
|
34
|
+
.optional(),
|
|
35
|
+
})
|
|
36
|
+
.refine((data) => data.kind === 'text' || (data.choices !== undefined && data.choices.length > 0), { message: 'choice/multi kinds require non-empty choices[]' });
|
|
37
|
+
export const WIDGET_SCHEMAS = {
|
|
38
|
+
'dep-graph': depGraphSchema,
|
|
39
|
+
'bar-chart': barChartSchema,
|
|
40
|
+
'stack-frames': stackFramesSchema,
|
|
41
|
+
'diff-viewer': diffSchema,
|
|
42
|
+
question: questionSchema, // NEW
|
|
43
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab-completion for `@<filename>` tokens in the REPL.
|
|
3
|
+
*
|
|
4
|
+
* UX contract:
|
|
5
|
+
* - User types `@` then Tab → all .cspeach.json files in workspace
|
|
6
|
+
* - User types `@b9b` then Tab → 1 match auto-completes; many lists
|
|
7
|
+
* - Mid-line: `/abap-design --status @b9b<Tab>`
|
|
8
|
+
* → completes only the @-token, line tail preserved
|
|
9
|
+
* - No `@` on the line → atCompleter returns null; caller
|
|
10
|
+
* falls through to the next completer
|
|
11
|
+
*
|
|
12
|
+
* Backed by readline's native `completer`, like slashCompleter — same
|
|
13
|
+
* `[string[], string]` shape so a thin wrapper can dispatch between the two.
|
|
14
|
+
*
|
|
15
|
+
* Performance: the workspace folder is read on every call but cached for
|
|
16
|
+
* a few seconds so per-keystroke listing doesn't thrash the filesystem.
|
|
17
|
+
* The cache TTL is short enough that newly-saved files appear within a
|
|
18
|
+
* few seconds of their save without manual refresh.
|
|
19
|
+
*/
|
|
20
|
+
import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
|
|
21
|
+
const CACHE_TTL_MS = 3000;
|
|
22
|
+
let cache = null;
|
|
23
|
+
function getCachedFiles() {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
if (!cache || now - cache.at > CACHE_TTL_MS) {
|
|
26
|
+
try {
|
|
27
|
+
const ws = ensureWorkspaceSync();
|
|
28
|
+
cache = { files: listProjectFilesSync(ws), at: now };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
cache = { files: [], at: now };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return cache.files;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* readline-style completer for `@<token>`. Returns `null` when the cursor
|
|
38
|
+
* is NOT inside an @-token, so the caller knows to delegate.
|
|
39
|
+
*
|
|
40
|
+
* `line` is the full input up to the cursor (Node's readline contract).
|
|
41
|
+
* We anchor on the rightmost `@<word>` because the @-token always sits
|
|
42
|
+
* at the cursor end during typing.
|
|
43
|
+
*/
|
|
44
|
+
export function atCompleter(line) {
|
|
45
|
+
const m = /@(\S*)$/.exec(line);
|
|
46
|
+
if (!m)
|
|
47
|
+
return null;
|
|
48
|
+
const token = m[1] ?? '';
|
|
49
|
+
const lineBefore = line.slice(0, m.index);
|
|
50
|
+
const lower = token.toLowerCase();
|
|
51
|
+
const files = getCachedFiles();
|
|
52
|
+
const matched = files.filter((f) => lower === '' || f.filename.toLowerCase().includes(lower));
|
|
53
|
+
if (matched.length === 0) {
|
|
54
|
+
// Return an empty hit list (not null) so readline knows we OWNED this
|
|
55
|
+
// completion — emitting "no matches" beep instead of falling through.
|
|
56
|
+
return [[], line];
|
|
57
|
+
}
|
|
58
|
+
const hits = matched.map((f) => `${lineBefore}@${f.filename}`);
|
|
59
|
+
return [hits, line];
|
|
60
|
+
}
|
|
61
|
+
/** Test-only: invalidate the cache so unit tests see fresh fs state. */
|
|
62
|
+
export function _resetAtCompleterCache() {
|
|
63
|
+
cache = null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live picker for `@<filename>` workspace tokens. Opens when the user
|
|
3
|
+
* presses Enter on a line that ends in a partial / unresolved @-token —
|
|
4
|
+
* mirrors openSlashPicker but for files instead of slash commands.
|
|
5
|
+
*
|
|
6
|
+
* UX contract:
|
|
7
|
+
* - User types `... @` then Enter → picker shows ALL workspace files
|
|
8
|
+
* - User types `... @b9b` then Enter → picker pre-filtered to those
|
|
9
|
+
* files matching `b9b`. User can refine further or pick.
|
|
10
|
+
* - User types `... @<exact-filename>` then Enter → no picker; the line
|
|
11
|
+
* is treated as already-resolved (existing @-token resolution path).
|
|
12
|
+
* - User cancels (Esc / Ctrl-C inside picker) → returns null; caller
|
|
13
|
+
* restores the original line so the user can edit it.
|
|
14
|
+
*
|
|
15
|
+
* Pairs with at-completer.ts (Tab completion for the same tokens).
|
|
16
|
+
* Tab vs Enter is the user's choice: Tab when they want a quick list
|
|
17
|
+
* without screen takeover, Enter when they want a full search-as-you-type
|
|
18
|
+
* picker. Same backing data, different UI.
|
|
19
|
+
*/
|
|
20
|
+
import { search } from '@inquirer/prompts';
|
|
21
|
+
import { withInquirer } from './inquirer-guard.js';
|
|
22
|
+
import { inquirerTheme } from './inquirer-theme.js';
|
|
23
|
+
import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
|
|
24
|
+
const CANCEL_VALUE = '__cspeach_cancel__';
|
|
25
|
+
/**
|
|
26
|
+
* Open the at-picker pre-filtered to `prefix`. Returns the picked
|
|
27
|
+
* filename (no leading @), or null on cancel / empty-workspace.
|
|
28
|
+
*
|
|
29
|
+
* `prefix` is whatever the user typed AFTER the @ — e.g. for line
|
|
30
|
+
* `/abap-rap --from @b9b<Enter>` the prefix is `b9b`. Empty prefix
|
|
31
|
+
* (bare `@`) seeds the picker with all workspace files.
|
|
32
|
+
*/
|
|
33
|
+
export async function openAtPicker(prefix) {
|
|
34
|
+
let allFiles;
|
|
35
|
+
try {
|
|
36
|
+
const ws = ensureWorkspaceSync();
|
|
37
|
+
allFiles = listProjectFilesSync(ws).map((f) => ({ filename: f.filename, mtimeMs: f.mtimeMs }));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
allFiles = [];
|
|
41
|
+
}
|
|
42
|
+
if (allFiles.length === 0) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const lowerPrefix = prefix.trim().toLowerCase();
|
|
46
|
+
const seeded = lowerPrefix.length === 0
|
|
47
|
+
? allFiles
|
|
48
|
+
: allFiles.filter((f) => f.filename.toLowerCase().includes(lowerPrefix));
|
|
49
|
+
// Single unique match — short-circuit, save the user a keystroke. Same
|
|
50
|
+
// shortcut openSlashPicker uses.
|
|
51
|
+
if (seeded.length === 1) {
|
|
52
|
+
return seeded[0].filename;
|
|
53
|
+
}
|
|
54
|
+
// Empty filter result → fall back to all files so the user can correct
|
|
55
|
+
// their prefix rather than seeing an empty picker.
|
|
56
|
+
const choicesForPicker = seeded.length === 0 ? allFiles : seeded;
|
|
57
|
+
try {
|
|
58
|
+
const picked = await withInquirer(() => search({
|
|
59
|
+
message: 'Pick a workspace file',
|
|
60
|
+
theme: inquirerTheme,
|
|
61
|
+
source: async (input) => {
|
|
62
|
+
const filter = (input ?? '').toLowerCase();
|
|
63
|
+
const filtered = filter.length === 0
|
|
64
|
+
? choicesForPicker
|
|
65
|
+
: choicesForPicker.filter((f) => f.filename.toLowerCase().includes(filter));
|
|
66
|
+
const cancelChoice = {
|
|
67
|
+
name: '← Cancel — keep my typed text and let me edit it',
|
|
68
|
+
value: CANCEL_VALUE,
|
|
69
|
+
short: 'cancel',
|
|
70
|
+
};
|
|
71
|
+
const choices = filtered.map((f) => ({
|
|
72
|
+
name: f.filename,
|
|
73
|
+
value: f.filename,
|
|
74
|
+
short: f.filename,
|
|
75
|
+
}));
|
|
76
|
+
return [cancelChoice, ...choices];
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
if (picked === CANCEL_VALUE)
|
|
80
|
+
return null;
|
|
81
|
+
return typeof picked === 'string' ? picked : null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Ctrl+C inside the picker — treat as cancellation.
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Quick check: does `trimmed` end with an @-token whose value (the part
|
|
90
|
+
* after @) doesn't already exactly match a workspace file? Used by the
|
|
91
|
+
* REPL to decide between "pass through to existing @-token resolution"
|
|
92
|
+
* and "open picker for ambiguous input".
|
|
93
|
+
*
|
|
94
|
+
* Returns the rightmost @-token's prefix (the chars typed after @), or
|
|
95
|
+
* null when the line doesn't end with an @-token or the token already
|
|
96
|
+
* resolves exactly.
|
|
97
|
+
*/
|
|
98
|
+
export function pendingAtTokenPrefix(trimmed) {
|
|
99
|
+
// Match the rightmost @<word> at the end of the line. \S+ means
|
|
100
|
+
// non-whitespace only — `@filename with space` is not auto-detected
|
|
101
|
+
// (user can quote with `@"name with space"` for those, handled by
|
|
102
|
+
// the existing parseFromFlag / resolveAtToken paths).
|
|
103
|
+
const m = /@(\S*)$/.exec(trimmed);
|
|
104
|
+
if (!m)
|
|
105
|
+
return null;
|
|
106
|
+
const prefix = m[1] ?? '';
|
|
107
|
+
// If the prefix already exactly matches a workspace filename, the
|
|
108
|
+
// existing @-resolution will pick it up — don't open the picker.
|
|
109
|
+
if (prefix.length > 0) {
|
|
110
|
+
try {
|
|
111
|
+
const ws = ensureWorkspaceSync();
|
|
112
|
+
const files = listProjectFilesSync(ws);
|
|
113
|
+
const exact = files.some((f) => f.filename === prefix);
|
|
114
|
+
if (exact)
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Fall through — open picker as a safe default.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return prefix;
|
|
122
|
+
}
|