@cspeach/cli 0.6.4 → 0.7.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/dist/agent/intent-system-prompt.js +46 -0
- package/dist/agent/loop.js +180 -18
- package/dist/agent/parallel-write-guard.js +71 -0
- package/dist/agent/repair-partial.js +88 -1
- package/dist/agent/retry-cap.js +121 -0
- package/dist/agent/summarise-via-provider.js +51 -0
- package/dist/approvals/jwt.js +33 -12
- package/dist/commands/auto-compact.js +94 -0
- package/dist/commands/compact.js +265 -0
- package/dist/commands/cost.js +56 -0
- package/dist/commands/help.js +21 -14
- package/dist/config/loader.js +39 -0
- package/dist/cost/cost-log.js +113 -0
- package/dist/cost/pricing.js +91 -0
- package/dist/index.js +0 -0
- package/dist/one-shot.js +1 -1
- package/dist/renderer/markdown.js +7 -1
- package/dist/renderer/status-footer.js +37 -14
- package/dist/renderer/thinking-heartbeat.js +15 -3
- package/dist/renderer/tool-widget.js +6 -1
- package/dist/renderer/tty.js +27 -0
- package/dist/repl/cspeach-shell-detect.js +33 -0
- package/dist/repl/post-turn-status.js +68 -0
- package/dist/repl/slash-completer.js +2 -0
- package/dist/repl/slash-picker.js +2 -0
- package/dist/repl.js +371 -46
- package/dist/sap-errors/parse-adt-exception.js +149 -0
- package/dist/session/schema.js +2 -1
- package/dist/session/store.js +19 -0
- package/dist/tools/_filesystem-shared.js +9 -1
- package/dist/tools/ask-question.js +19 -0
- package/dist/tools/sap-read.js +56 -4
- package/dist/tools/sap-write.js +19 -0
- package/dist/tools/subagent/schedule_draft_create.js +137 -0
- package/dist/ui/alt-screen.js +120 -0
- package/dist/ui/app.js +39 -15
- package/dist/ui/ask-question-emitter.js +13 -0
- package/dist/ui/body.js +39 -23
- package/dist/ui/coaching-picker-classic.js +8 -1
- package/dist/ui/footer.js +6 -1
- package/dist/ui/header.js +19 -4
- package/dist/ui/sap-state-store.js +13 -1
- package/dist/ui/sidebar.js +5 -3
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-row.js +30 -4
- package/dist/ui/widgets/ask-question-modal.js +132 -0
- package/dist/ui/widgets/coaching-picker.js +55 -8
- package/package.json +86 -82
- package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
- package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
- package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
- package/dist/agent/__tests__/project-context-injection.test.js +0 -161
- package/dist/agent/__tests__/repair-partial.test.js +0 -79
- package/dist/agent/__tests__/retry-key.test.js +0 -77
- package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
- package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
- package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
- package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
- package/dist/agent/__tests__/turn-stream.test.js +0 -100
- package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
- package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
- package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
- package/dist/agent/providers/__tests__/factory.test.js +0 -33
- package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
- package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
- package/dist/approvals/__tests__/advisory-render.test.js +0 -54
- package/dist/auth/__tests__/auth-file.test.js +0 -42
- package/dist/auth/__tests__/me.test.js +0 -43
- package/dist/classifier/__tests__/client.test.js +0 -70
- package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
- package/dist/commands/__tests__/login.test.js +0 -152
- package/dist/commands/__tests__/logout.test.js +0 -39
- package/dist/commands/__tests__/project-context-impact.test.js +0 -173
- package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
- package/dist/commands/__tests__/whoami.test.js +0 -91
- package/dist/config/__tests__/llm-config.test.js +0 -28
- package/dist/config/__tests__/shell-exec-config.test.js +0 -76
- package/dist/config/__tests__/write-mode.test.js +0 -36
- package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
- package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
- package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
- package/dist/project-context/__tests__/conventions.test.js +0 -221
- package/dist/project-context/__tests__/detect.test.js +0 -177
- package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
- package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
- package/dist/project-context/__tests__/domain-cap.test.js +0 -168
- package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
- package/dist/project-context/__tests__/git.test.js +0 -131
- package/dist/project-context/__tests__/index-files.test.js +0 -195
- package/dist/project-context/__tests__/index.test.js +0 -133
- package/dist/project-context/__tests__/render.test.js +0 -322
- package/dist/project-context/__tests__/types.test.js +0 -73
- package/dist/projects/__tests__/build.test.js +0 -52
- package/dist/projects/__tests__/canonicalize.test.js +0 -47
- package/dist/projects/__tests__/email-template.test.js +0 -71
- package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
- package/dist/projects/__tests__/extract-cca.test.js +0 -141
- package/dist/projects/__tests__/extract-design.test.js +0 -62
- package/dist/projects/__tests__/extract-estimate.test.js +0 -58
- package/dist/projects/__tests__/extract-modernize.test.js +0 -127
- package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
- package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
- package/dist/projects/__tests__/filename.test.js +0 -32
- package/dist/projects/__tests__/promote-command.test.js +0 -170
- package/dist/projects/__tests__/promote.test.js +0 -181
- package/dist/projects/__tests__/save-command.test.js +0 -216
- package/dist/projects/__tests__/save.test.js +0 -41
- package/dist/projects/__tests__/status.test.js +0 -208
- package/dist/projects/__tests__/types.test.js +0 -41
- package/dist/projects/__tests__/validate.test.js +0 -165
- package/dist/projects/__tests__/workspace.test.js +0 -337
- package/dist/repl/__tests__/current-transport.test.js +0 -43
- package/dist/repl/__tests__/diff-display.test.js +0 -59
- package/dist/repl/__tests__/file-picker.test.js +0 -97
- package/dist/repl/__tests__/rule8-detector.test.js +0 -114
- package/dist/repl/__tests__/safety-confirm.test.js +0 -130
- package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
- package/dist/sap/__tests__/system-info.test.js +0 -344
- package/dist/session/__tests__/store-discovery.test.js +0 -114
- package/dist/session/__tests__/time-ago.test.js +0 -53
- package/dist/skills/__tests__/canonical.test.js +0 -20
- package/dist/skills/__tests__/manifest-client.test.js +0 -88
- package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
- package/dist/skills/__tests__/source-managed.test.js +0 -60
- package/dist/tools/__tests__/_background-shared.test.js +0 -69
- package/dist/tools/__tests__/_command-shared.test.js +0 -59
- package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
- package/dist/tools/__tests__/_flag.test.js +0 -66
- package/dist/tools/__tests__/_helpers.js +0 -17
- package/dist/tools/__tests__/_web-shared.test.js +0 -143
- package/dist/tools/__tests__/agent_run.test.js +0 -166
- package/dist/tools/__tests__/approval-advisory.test.js +0 -66
- package/dist/tools/__tests__/background_run.test.js +0 -113
- package/dist/tools/__tests__/convention_get.test.js +0 -53
- package/dist/tools/__tests__/file-edit.test.js +0 -72
- package/dist/tools/__tests__/file-read.test.js +0 -81
- package/dist/tools/__tests__/file-write.test.js +0 -68
- package/dist/tools/__tests__/glob.test.js +0 -128
- package/dist/tools/__tests__/grep.test.js +0 -71
- package/dist/tools/__tests__/monitor_emit.test.js +0 -96
- package/dist/tools/__tests__/playbook_get.test.js +0 -109
- package/dist/tools/__tests__/project_context_get.test.js +0 -71
- package/dist/tools/__tests__/registry-category.test.js +0 -60
- package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
- package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
- package/dist/tools/__tests__/schedule_create.test.js +0 -85
- package/dist/tools/__tests__/shell_exec.test.js +0 -135
- package/dist/tools/__tests__/transport-safety.test.js +0 -106
- package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
- package/dist/tools/__tests__/web_fetch.test.js +0 -329
- package/dist/tools/__tests__/web_search.test.js +0 -215
- package/dist/tools/__tests__/write-mode.test.js +0 -32
- package/dist/ui/__tests__/login-banner.test.js +0 -69
package/dist/ui/app.js
CHANGED
|
@@ -2,8 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, useInput } from 'ink';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
-
import { Header } from './header.js';
|
|
6
|
-
import { Sidebar } from './sidebar.js';
|
|
7
5
|
import { Body } from './body.js';
|
|
8
6
|
import { Footer } from './footer.js';
|
|
9
7
|
import { ApprovalModal } from './approval-modal.js';
|
|
@@ -15,17 +13,29 @@ import { errorEmitter } from './error-emitter.js';
|
|
|
15
13
|
import { SapStateContext, globalStore } from './sap-state-store.js';
|
|
16
14
|
import { coachingPickerEmitter } from './coaching-picker-emitter.js';
|
|
17
15
|
import { CoachingPicker } from './widgets/coaching-picker.js';
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
import { askQuestionEmitter } from './ask-question-emitter.js';
|
|
17
|
+
import { AskQuestionModal } from './widgets/ask-question-modal.js';
|
|
18
|
+
/**
|
|
19
|
+
* App — inline-mode Ink frame (Phase D6, 2026-05-17). After the
|
|
20
|
+
* alt-screen detour, reverted to CC's architecture:
|
|
21
|
+
*
|
|
22
|
+
* <Header /> ← renders once, becomes scrollback
|
|
23
|
+
* <Body chunkEmitter=… /> ← <Static> commits each node, becomes scrollback
|
|
24
|
+
* {coachingPending && …} ← inline overlays that mount above input
|
|
25
|
+
* {askQuestionPending && …}
|
|
26
|
+
* <Footer /> ← dynamic, re-renders at cursor (sticky-ish)
|
|
27
|
+
*
|
|
28
|
+
* No alt-screen, no termHeight, no PgUp scroll state. The terminal's
|
|
29
|
+
* native scrollback handles all of it — mouse wheel, PgUp, search,
|
|
30
|
+
* everything just works because we render inline like a normal CLI.
|
|
31
|
+
*/
|
|
32
|
+
export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, writeMode, onInterrupt }) {
|
|
21
33
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
22
34
|
const [coachingPending, setCoachingPending] = useState(null);
|
|
23
|
-
|
|
35
|
+
const [askQuestionPending, setAskQuestionPending] = useState(null);
|
|
24
36
|
const [showPalette, setShowPalette] = useState(false);
|
|
25
37
|
const [paletteResult, setPaletteResult] = useState('');
|
|
26
|
-
// Task 27: error stack overlay state.
|
|
27
38
|
const [errorStack, setErrorStack] = useState([]);
|
|
28
|
-
// Task 29: session timeline overlay state.
|
|
29
39
|
const [showTimeline, setShowTimeline] = useState(false);
|
|
30
40
|
const [footerValue, setFooterValue] = useState('');
|
|
31
41
|
useEffect(() => {
|
|
@@ -44,6 +54,13 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
44
54
|
coachingPickerEmitter.off('request', onRequest);
|
|
45
55
|
};
|
|
46
56
|
}, []);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const onRequest = (req) => {
|
|
59
|
+
setAskQuestionPending(req);
|
|
60
|
+
};
|
|
61
|
+
askQuestionEmitter.on('request', onRequest);
|
|
62
|
+
return () => { askQuestionEmitter.off('request', onRequest); };
|
|
63
|
+
}, []);
|
|
47
64
|
useEffect(() => {
|
|
48
65
|
const onError = (err) => {
|
|
49
66
|
const id = crypto.randomUUID();
|
|
@@ -56,12 +73,16 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
56
73
|
return () => { errorEmitter.off('error', onError); };
|
|
57
74
|
}, []);
|
|
58
75
|
// `?` key opens the timeline when the footer is empty and no other overlay
|
|
59
|
-
// is active.
|
|
60
|
-
// when the user is typing.
|
|
76
|
+
// is active.
|
|
61
77
|
useInput((input, _key) => {
|
|
62
78
|
if (input === '?' && footerValue === '')
|
|
63
79
|
setShowTimeline(true);
|
|
64
80
|
}, { isActive: !showPalette && !showTimeline && !pendingApproval && !coachingPending });
|
|
81
|
+
// Ctrl+C handler — abort in-flight turn or exit at idle.
|
|
82
|
+
useInput((input, key) => {
|
|
83
|
+
if (key.ctrl && input === 'c')
|
|
84
|
+
onInterrupt?.();
|
|
85
|
+
}, { isActive: true });
|
|
65
86
|
const clearApproval = () => setPendingApproval(null);
|
|
66
87
|
const handleSlashTrigger = () => {
|
|
67
88
|
setShowPalette(true);
|
|
@@ -76,13 +97,16 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
76
97
|
.filter((c) => c.completed === true)
|
|
77
98
|
.map(toTimelineEntry)
|
|
78
99
|
: [];
|
|
79
|
-
return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column",
|
|
100
|
+
return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Body, { chunkEmitter: chunkEmitter }), coachingPending && (_jsx(CoachingPicker, { params: coachingPending.params, onResolve: (result) => {
|
|
101
|
+
coachingPending.resolve(result);
|
|
102
|
+
setCoachingPending(null);
|
|
103
|
+
} })), askQuestionPending && (_jsx(AskQuestionModal, { params: askQuestionPending, onResolve: (result) => {
|
|
104
|
+
askQuestionPending.resolve(result);
|
|
105
|
+
setAskQuestionPending(null);
|
|
106
|
+
} })), _jsx(Footer, { onSubmit: onSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, paletteResult: paletteResult, onValueChange: setFooterValue }), showPalette && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { onSelect: (s) => {
|
|
80
107
|
setPaletteResult(s);
|
|
81
108
|
setShowPalette(false);
|
|
82
|
-
}, onCancel: () => setShowPalette(false) }) })), errorStack.map((e
|
|
83
|
-
coachingPending.resolve(result);
|
|
84
|
-
setCoachingPending(null);
|
|
85
|
-
} }) })), pendingApproval && (_jsx(Box, { position: "absolute", marginTop: 2, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
|
|
109
|
+
}, onCancel: () => setShowPalette(false) }) })), errorStack.map((e) => (_jsx(Box, { children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
|
|
86
110
|
pendingApproval.resolve({ approved: true });
|
|
87
111
|
clearApproval();
|
|
88
112
|
}, onSkip: () => {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
class AskQuestionEmitter extends EventEmitter {
|
|
3
|
+
request(params) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
if (this.listenerCount('request') === 0) {
|
|
6
|
+
resolve({ answer: null, cancelled: true });
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
this.emit('request', { ...params, resolve });
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export const askQuestionEmitter = new AskQuestionEmitter();
|
package/dist/ui/body.js
CHANGED
|
@@ -1,40 +1,31 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import React, { useReducer, useEffect } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { Text, Static } from 'ink';
|
|
4
4
|
import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
|
|
5
5
|
import { WIDGET_CONTROL } from './widget-control.js';
|
|
6
|
-
// WIDGET_CONTROL is the longer sentinel (`\x00CSPEACH-WIDGET-V3\x00`)
|
|
6
|
+
// WIDGET_CONTROL is the longer sentinel (`\x00CSPEACH-WIDGET-V3\x00`).
|
|
7
7
|
// RegExp escaping: `\x00` works in JS regex source directly.
|
|
8
8
|
const WIDGET_RE = new RegExp(WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
9
9
|
'(\\d+):(\\w[\\w-]*):([\\w+/=]+)' +
|
|
10
10
|
WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
|
|
11
|
-
// Eviction caps (H-new-1).
|
|
11
|
+
// Eviction caps (H-new-1) — bound React state size on long sessions.
|
|
12
12
|
const MAX_NODES = 500;
|
|
13
13
|
const MAX_TEXT_LEN = 100_000;
|
|
14
14
|
export const INITIAL_BODY_STATE = { nodes: [], pending: '', nextId: 0, totalTextLen: 0 };
|
|
15
|
-
/**
|
|
16
|
-
* Parse an incremental chunk into a list of new nodes + any unterminated tail
|
|
17
|
-
* that should stay in `pending`. Only the NEW content (pending + chunk) is
|
|
18
|
-
* scanned — previously-parsed nodes are never re-regexed.
|
|
19
|
-
*/
|
|
20
15
|
function parseIncremental(pending, chunk) {
|
|
21
16
|
const combined = pending + chunk;
|
|
22
|
-
// If an unterminated WIDGET_CONTROL appears at the tail, defer from that point.
|
|
23
17
|
const lastOpenerIdx = combined.lastIndexOf(WIDGET_CONTROL);
|
|
24
18
|
let scannable = combined;
|
|
25
19
|
let nextPending = '';
|
|
26
20
|
if (lastOpenerIdx !== -1) {
|
|
27
|
-
// Check whether there's a matching closer after it.
|
|
28
21
|
const afterOpener = combined.slice(lastOpenerIdx + WIDGET_CONTROL.length);
|
|
29
22
|
if (!afterOpener.includes(WIDGET_CONTROL)) {
|
|
30
|
-
// Unterminated — buffer from the opener onwards.
|
|
31
23
|
scannable = combined.slice(0, lastOpenerIdx);
|
|
32
24
|
nextPending = combined.slice(lastOpenerIdx);
|
|
33
25
|
}
|
|
34
26
|
}
|
|
35
27
|
const newNodes = [];
|
|
36
28
|
const parts = scannable.split(WIDGET_RE);
|
|
37
|
-
// split with 3 groups produces 4 slots per widget: [text, idx, type, b64, text, ...]
|
|
38
29
|
for (let i = 0; i < parts.length; i++) {
|
|
39
30
|
const mod = i % 4;
|
|
40
31
|
if (mod === 0) {
|
|
@@ -56,7 +47,7 @@ function parseIncremental(pending, chunk) {
|
|
|
56
47
|
catch {
|
|
57
48
|
newNodes.push({ kind: 'widget-error' });
|
|
58
49
|
}
|
|
59
|
-
i += 2;
|
|
50
|
+
i += 2;
|
|
60
51
|
}
|
|
61
52
|
}
|
|
62
53
|
return { newNodes, nextPending };
|
|
@@ -72,7 +63,6 @@ export function bodyReducer(state, action) {
|
|
|
72
63
|
return withId;
|
|
73
64
|
});
|
|
74
65
|
let nodes = state.nodes.concat(appended);
|
|
75
|
-
// Sliding-window eviction: drop oldest nodes until both caps satisfied.
|
|
76
66
|
while (nodes.length > MAX_NODES || totalTextLen > MAX_TEXT_LEN) {
|
|
77
67
|
const [evicted, ...rest] = nodes;
|
|
78
68
|
if (!evicted)
|
|
@@ -83,6 +73,35 @@ export function bodyReducer(state, action) {
|
|
|
83
73
|
}
|
|
84
74
|
return { nodes, pending: nextPending, nextId: id, totalTextLen };
|
|
85
75
|
}
|
|
76
|
+
function renderNode(n) {
|
|
77
|
+
if (n.kind === 'text')
|
|
78
|
+
return _jsx(Text, { children: n.value }, n.id);
|
|
79
|
+
if (n.kind === 'widget') {
|
|
80
|
+
const Comp = WIDGET_COMPONENTS[n.widgetType];
|
|
81
|
+
return _jsx(Comp, { data: n.data }, n.id);
|
|
82
|
+
}
|
|
83
|
+
return _jsx(Text, { color: "yellow", children: "\u26A0 widget render error" }, n.id);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Inline-mode Body (Phase D6, 2026-05-17) — back to CC's architecture
|
|
87
|
+
* after the alt-screen detour was rejected.
|
|
88
|
+
*
|
|
89
|
+
* We render in INLINE mode (no alt-screen takeover). Ink renders to the
|
|
90
|
+
* normal terminal stream and the terminal's native scrollback handles
|
|
91
|
+
* mouse wheel, PgUp, search, etc. Two regions:
|
|
92
|
+
*
|
|
93
|
+
* - <Static items={nodes}>: each committed node renders ONCE, then
|
|
94
|
+
* becomes part of the terminal's scrollback buffer. Ink never
|
|
95
|
+
* redraws Static items, so they don't flicker, can't be clipped,
|
|
96
|
+
* and the user can scroll back through them naturally.
|
|
97
|
+
* - Dynamic tail (intentionally empty here): the Footer below is
|
|
98
|
+
* the dynamic region — its re-renders happen at the cursor's
|
|
99
|
+
* bottom position via cursor-up + clear-line.
|
|
100
|
+
*
|
|
101
|
+
* The previous attempt used <Static> in alt-screen mode where Static's
|
|
102
|
+
* "commit to scrollback" semantic broke (alt-screen has no scrollback).
|
|
103
|
+
* In inline mode <Static> is exactly the right primitive.
|
|
104
|
+
*/
|
|
86
105
|
export const Body = React.memo(function Body({ chunkEmitter }) {
|
|
87
106
|
const [state, dispatch] = useReducer(bodyReducer, INITIAL_BODY_STATE);
|
|
88
107
|
useEffect(() => {
|
|
@@ -90,13 +109,10 @@ export const Body = React.memo(function Body({ chunkEmitter }) {
|
|
|
90
109
|
chunkEmitter.on('chunk', handler);
|
|
91
110
|
return () => { chunkEmitter.off('chunk', handler); };
|
|
92
111
|
}, [chunkEmitter]);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
return _jsx(Text, { color: "yellow", children: "\\u26A0 widget render error" }, n.id);
|
|
101
|
-
}) }));
|
|
112
|
+
// <Static> takes the nodes array; on each render it appends ONLY new
|
|
113
|
+
// items since the last render. Already-rendered items are not
|
|
114
|
+
// re-painted — they become part of the terminal's scrollback. This
|
|
115
|
+
// is what makes Ink behave like a regular terminal program (CC, npm,
|
|
116
|
+
// tsc, etc.) in inline mode.
|
|
117
|
+
return (_jsx(Static, { items: state.nodes, children: (n) => renderNode(n) }));
|
|
102
118
|
});
|
|
@@ -20,7 +20,14 @@ export async function coachingPickerClassic(params) {
|
|
|
20
20
|
value: CANCEL_VALUE,
|
|
21
21
|
});
|
|
22
22
|
const message = `I'm not sure which skill fits for: "${params.prompt.slice(0, 50)}${params.prompt.length > 50 ? '...' : ''}". Pick one:`;
|
|
23
|
-
|
|
23
|
+
// 2026-05-17 — preferSkill (the just-used skill) takes precedence over
|
|
24
|
+
// preSelect when present AND in options, so a follow-up question can be
|
|
25
|
+
// confirmed with a single Enter press.
|
|
26
|
+
const preferred = params.preferSkill
|
|
27
|
+
? params.options.find((o) => o.skill === params.preferSkill)
|
|
28
|
+
: undefined;
|
|
29
|
+
const defaultValue = preferred?.skill
|
|
30
|
+
?? (params.preSelect && params.options.length > 0 ? params.options[0].skill : undefined);
|
|
24
31
|
try {
|
|
25
32
|
const chosen = await select({ message, choices, default: defaultValue });
|
|
26
33
|
if (chosen === CANCEL_VALUE)
|
package/dist/ui/footer.js
CHANGED
|
@@ -99,5 +99,10 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
|
|
|
99
99
|
if (fuzzyMode) {
|
|
100
100
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search..." }), fuzzyResults.map((r, i) => _jsx(Text, { dimColor: i > 0, children: r }, i)), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
|
|
101
101
|
}
|
|
102
|
-
return (
|
|
102
|
+
return (
|
|
103
|
+
// Phase D6 (2026-05-17) — borderless input. The border had pushed
|
|
104
|
+
// the terminal cursor one line below the input (the "booger"); now
|
|
105
|
+
// both the terminal cursor AND TextInput's block character land at
|
|
106
|
+
// the same position, appearing as a single blinking cursor.
|
|
107
|
+
_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })] }));
|
|
103
108
|
}
|
package/dist/ui/header.js
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { useSapState } from './sap-state-store.js';
|
|
4
|
+
import { CLI_VERSION } from '../lib/version.js';
|
|
4
5
|
const PEACH = '#FFCBA4';
|
|
6
|
+
/**
|
|
7
|
+
* Single-line rich header (Phase D3, 2026-05-16) — back to one line per
|
|
8
|
+
* Laeeq's feedback that the two-line variant felt worse. Uses tighter
|
|
9
|
+
* separators and shorter labels so it fits cleanly on typical terminals
|
|
10
|
+
* (100+ cols). On a narrower terminal Ink will wrap rather than truncate,
|
|
11
|
+
* accepted as a degradation case.
|
|
12
|
+
*
|
|
13
|
+
* Layout:
|
|
14
|
+
* 🍑 CSPeach 0.6.5 │ S4H │ S.LAEEQ │ tr none │ 28k tok │ $0.54 │ approval-gated
|
|
15
|
+
*/
|
|
5
16
|
export function Header({ writeMode } = {}) {
|
|
6
|
-
const
|
|
17
|
+
const s = useSapState();
|
|
7
18
|
const isAdvisory = writeMode === 'advisory-only';
|
|
8
19
|
const modeLabel = isAdvisory
|
|
9
|
-
? 'ADVISORY
|
|
20
|
+
? 'ADVISORY'
|
|
10
21
|
: writeMode === 'approval-gated'
|
|
11
22
|
? 'approval-gated'
|
|
12
23
|
: writeMode === 'auto'
|
|
13
24
|
? 'auto'
|
|
14
25
|
: undefined;
|
|
15
26
|
const modeColor = isAdvisory ? 'yellow' : 'green';
|
|
16
|
-
|
|
27
|
+
const tokDisplay = s.tokenUsageToday >= 1000
|
|
28
|
+
? `${(s.tokenUsageToday / 1000).toFixed(1)}k tok`
|
|
29
|
+
: `${s.tokenUsageToday} tok`;
|
|
30
|
+
const spendDisplay = `$${s.dollarSpendToday.toFixed(2)}`;
|
|
31
|
+
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, color: PEACH, children: ["\uD83C\uDF51 CSPeach ", CLI_VERSION] }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "cyan", children: s.system || 'no system' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { color: "gray", children: " \u2502 tr " }), _jsx(Text, { color: "yellow", children: s.transport ?? 'none' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: tokDisplay }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay }), modeLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: modeColor, bold: isAdvisory, children: modeLabel })] }))] }));
|
|
17
32
|
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import React, { createContext, useContext } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Phase A #3 (2026-05-16) — `dollarCap` was hard-coded to 10. Live evidence
|
|
4
|
+
* shows real sessions hit $50-150 routinely (one /abap-radar + 30 explain
|
|
5
|
+
* turns landed at $167 today). A $10 cap turned the sidebar spend bar
|
|
6
|
+
* red within the first minute of every session, training users to
|
|
7
|
+
* ignore the colour signal. New default 100; the cap is purely a UI
|
|
8
|
+
* coloring threshold — there is no actual enforcement.
|
|
9
|
+
*
|
|
10
|
+
* Future: thread from a [sidebar] config block so power users can lower
|
|
11
|
+
* (tight budget) or raise (heavy build day) the threshold. Deferred to
|
|
12
|
+
* Phase B / C — see docs/2026-05-16-ink-polish-research.md.
|
|
13
|
+
*/
|
|
2
14
|
const DEFAULT_STATE = {
|
|
3
15
|
system: '', user: '', transport: null, locksHeld: [],
|
|
4
|
-
tokenUsageToday: 0, dollarSpendToday: 0, dollarCap:
|
|
16
|
+
tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 100, recentTurns: [],
|
|
5
17
|
};
|
|
6
18
|
/**
|
|
7
19
|
* Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
|
package/dist/ui/sidebar.js
CHANGED
|
@@ -3,8 +3,10 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { useSapState } from './sap-state-store.js';
|
|
4
4
|
export function Sidebar() {
|
|
5
5
|
const s = useSapState();
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// Phase A** (2026-05-16) — dropped the "/ of $100" pseudo-budget. We
|
|
7
|
+
// don't know the user's actual API spending limit; showing a fake
|
|
8
|
+
// cap trained the colour signal to be meaningless. Now just shows the
|
|
9
|
+
// raw spend. Colour signal removed alongside.
|
|
8
10
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, width: 30, children: [_jsx(Text, { bold: true, color: "cyan", children: s.system || 'no system' }), _jsxs(Text, { color: "gray", children: ["user: ", _jsx(Text, { color: "white", children: s.user || '—' })] }), _jsxs(Text, { color: "gray", children: ["transport: ", _jsx(Text, { color: "yellow", children: s.transport ?? 'none' })] }), _jsxs(Text, { color: "gray", children: ["locks: ", s.locksHeld.length === 0 ? _jsx(Text, { dimColor: true, children: "(none)" })
|
|
9
|
-
: s.locksHeld.map((l, i) => _jsxs(Text, { color: "red", children: [l, " "] }, i))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color:
|
|
11
|
+
: s.locksHeld.map((l, i) => _jsxs(Text, { color: "red", children: [l, " "] }, i))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "green", children: [s.tokenUsageToday.toLocaleString(), " tok / $", s.dollarSpendToday.toFixed(2)] }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "\u2500 recent turns \u2500" }), s.recentTurns.slice(-5).reverse().map((t, i) => (_jsxs(Text, { dimColor: true, children: [t.time, " ", t.command.slice(0, 20)] }, i))), s.recentTurns.length === 0 && _jsx(Text, { dimColor: true, children: "(no turns yet)" })] })] }));
|
|
10
12
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
/**
|
|
3
|
+
* Singleton channel from `runTurn` (producer) to `<App>` (consumer) for
|
|
4
|
+
* status-row updates in Ink mode. Phase A #1 (2026-05-16) — the prior
|
|
5
|
+
* version of the REPL claimed Ink rendered its own `<StatusRow>` but
|
|
6
|
+
* never imported the component. As a result Ink users saw NO cost,
|
|
7
|
+
* token, or session info ever. This emitter is the missing wire.
|
|
8
|
+
*
|
|
9
|
+
* Producer (repl.tsx, end-of-turn): emits an `update` event with the
|
|
10
|
+
* latest StatusRowData. Consumer (App.tsx): subscribes in a useEffect,
|
|
11
|
+
* stores in state, renders `<StatusRow data={...} />` above `<Footer>`.
|
|
12
|
+
*
|
|
13
|
+
* Why a separate emitter (not chunkEmitter): chunkEmitter pipes strings
|
|
14
|
+
* into Body. StatusRow needs structured data (numbers + dollar amounts)
|
|
15
|
+
* and lives in a fixed region — passing it through chunks would mean
|
|
16
|
+
* sentinel parsing for what is naturally typed state. Matches the
|
|
17
|
+
* approvalEmitter / errorEmitter / coachingPickerEmitter pattern.
|
|
18
|
+
*/
|
|
19
|
+
export const statusEmitter = new EventEmitter();
|
package/dist/ui/status-row.js
CHANGED
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { formatCost } from '../cost/pricing.js';
|
|
4
|
+
import { LARGE_SESSION_THRESHOLD } from '../renderer/status-footer.js';
|
|
3
5
|
/**
|
|
4
|
-
* Single-line status row
|
|
5
|
-
* Mirrors the
|
|
6
|
-
* so users see the same fields
|
|
6
|
+
* Single-line status row mounted in the Ink frame above `<Footer>`.
|
|
7
|
+
* Mirrors the layout of `printStatusFooter` (renderer/status-footer.ts)
|
|
8
|
+
* so users see the same fields whichever mode they run in.
|
|
9
|
+
*
|
|
10
|
+
* Phase A #1 (2026-05-16): mounted into App.tsx for the first time —
|
|
11
|
+
* the component existed but no one rendered it. Also added the
|
|
12
|
+
* large-session advisory inline so N3 closes with this change.
|
|
7
13
|
*/
|
|
8
14
|
export function StatusRow({ data }) {
|
|
9
15
|
if (!data)
|
|
10
16
|
return _jsx(Box, {});
|
|
11
|
-
|
|
17
|
+
const shortSession = data.sessionId.slice(0, 8);
|
|
18
|
+
const skillDisplay = data.skill.startsWith('/') ? data.skill : `/${data.skill}`;
|
|
19
|
+
const turns = data.turnCount != null ? `${data.turnCount} turn${data.turnCount === 1 ? '' : 's'}` : '';
|
|
20
|
+
const tokStr = `${data.tokens.toLocaleString()} tok this turn`;
|
|
21
|
+
const cachedStr = data.cachedTokens && data.cachedTokens > 0 ? `${data.cachedTokens.toLocaleString()} cached` : '';
|
|
22
|
+
const elapsedStr = data.elapsedMs > 0 ? `${(data.elapsedMs / 1000).toFixed(1)}s` : '';
|
|
23
|
+
const costTurnStr = data.costThisTurn != null ? formatCost(data.costThisTurn) : '';
|
|
24
|
+
const costSessStr = data.costSession != null ? `session: ${formatCost(data.costSession)}` : '';
|
|
25
|
+
const parts = [
|
|
26
|
+
`session ${shortSession}`,
|
|
27
|
+
data.sapAlias,
|
|
28
|
+
skillDisplay,
|
|
29
|
+
...(turns ? [turns] : []),
|
|
30
|
+
tokStr,
|
|
31
|
+
...(costTurnStr ? [costTurnStr] : []),
|
|
32
|
+
...(cachedStr ? [cachedStr] : []),
|
|
33
|
+
...(costSessStr ? [costSessStr] : []),
|
|
34
|
+
...(elapsedStr ? [elapsedStr] : []),
|
|
35
|
+
];
|
|
36
|
+
const isLarge = data.sessionInputTokens != null && data.sessionInputTokens >= LARGE_SESSION_THRESHOLD;
|
|
37
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: parts.join(' │ ') }), isLarge && (_jsxs(Text, { color: "yellow", children: ["\u26A0 session getting large (", data.sessionInputTokens.toLocaleString(), " input tokens)", ' ', "\u2014 type /compact to summarise older turns, or /exit + cspeach (fresh) for a new topic"] }))] }));
|
|
12
38
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* AskQuestionModal — Ink-native interactive form for the ask_question tool.
|
|
4
|
+
*
|
|
5
|
+
* Critical #2 fix (2026-05-17): the prior path called inquirer.input/
|
|
6
|
+
* select/checkbox directly. In Ink that draws raw to stdout, corrupts
|
|
7
|
+
* the React frame, and the user often can't tell what's being asked.
|
|
8
|
+
*
|
|
9
|
+
* Three modes match the tool's three kinds:
|
|
10
|
+
* - text → free-form TextInput, Enter to submit, Esc to cancel
|
|
11
|
+
* - choice → arrow-key single-select list (mirrors CoachingPicker)
|
|
12
|
+
* - multi → space-to-toggle checkbox list, Enter to submit
|
|
13
|
+
*
|
|
14
|
+
* Mounted inline by App.tsx (NOT as position="absolute" overlay — we
|
|
15
|
+
* learned the hard way that absolute overlays paint over Body and
|
|
16
|
+
* can't be navigated cleanly).
|
|
17
|
+
*/
|
|
18
|
+
import { useState } from 'react';
|
|
19
|
+
import { Box, Text, useInput } from 'ink';
|
|
20
|
+
import TextInput from 'ink-text-input';
|
|
21
|
+
const PEACH = '#FFCBA4';
|
|
22
|
+
export function AskQuestionModal({ params, onResolve }) {
|
|
23
|
+
if (params.kind === 'text' || params.choices.length === 0) {
|
|
24
|
+
return _jsx(TextInputForm, { params: params, onResolve: onResolve });
|
|
25
|
+
}
|
|
26
|
+
if (params.kind === 'choice') {
|
|
27
|
+
return _jsx(ChoiceForm, { params: params, onResolve: onResolve });
|
|
28
|
+
}
|
|
29
|
+
return _jsx(MultiForm, { params: params, onResolve: onResolve });
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Free-form text
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
function TextInputForm({ params, onResolve }) {
|
|
35
|
+
const [value, setValue] = useState('');
|
|
36
|
+
useInput((_input, key) => {
|
|
37
|
+
if (key.escape) {
|
|
38
|
+
onResolve({ answer: null, cancelled: true });
|
|
39
|
+
}
|
|
40
|
+
}, { isActive: true });
|
|
41
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: PEACH, children: '> ' }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: (v) => {
|
|
42
|
+
const trimmed = v.trim();
|
|
43
|
+
if (trimmed.length === 0) {
|
|
44
|
+
onResolve({ answer: null, cancelled: true });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
onResolve({ answer: trimmed });
|
|
48
|
+
}
|
|
49
|
+
} })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter to submit \u00B7 Esc to cancel" }) })] }));
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Single choice (kind = 'choice')
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
function ChoiceForm({ params, onResolve }) {
|
|
55
|
+
const [cursor, setCursor] = useState(0);
|
|
56
|
+
useInput((_input, key) => {
|
|
57
|
+
if (key.escape) {
|
|
58
|
+
onResolve({ answer: null, cancelled: true });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (key.upArrow) {
|
|
62
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (key.downArrow) {
|
|
66
|
+
setCursor((c) => Math.min(params.choices.length - 1, c + 1));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (key.return) {
|
|
70
|
+
const chosen = params.choices[cursor];
|
|
71
|
+
if (chosen)
|
|
72
|
+
onResolve({ answer: chosen.value });
|
|
73
|
+
}
|
|
74
|
+
}, { isActive: true });
|
|
75
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.choices.map((c, i) => {
|
|
76
|
+
const isCursor = i === cursor;
|
|
77
|
+
return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [isCursor ? '▸ ' : ' ', c.label] }, c.value));
|
|
78
|
+
}) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" }) })] }));
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Multi choice (kind = 'multi') — space to toggle, Enter to submit
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
function MultiForm({ params, onResolve }) {
|
|
84
|
+
const [cursor, setCursor] = useState(0);
|
|
85
|
+
const [selected, setSelected] = useState(new Set());
|
|
86
|
+
useInput((input, key) => {
|
|
87
|
+
if (key.escape) {
|
|
88
|
+
onResolve({ answer: null, cancelled: true });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (key.upArrow) {
|
|
92
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key.downArrow) {
|
|
96
|
+
setCursor((c) => Math.min(params.choices.length - 1, c + 1));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (input === ' ') {
|
|
100
|
+
const current = params.choices[cursor];
|
|
101
|
+
if (current) {
|
|
102
|
+
setSelected((prev) => {
|
|
103
|
+
const next = new Set(prev);
|
|
104
|
+
if (next.has(current.value))
|
|
105
|
+
next.delete(current.value);
|
|
106
|
+
else
|
|
107
|
+
next.add(current.value);
|
|
108
|
+
return next;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.return) {
|
|
114
|
+
const values = params.choices
|
|
115
|
+
.filter((c) => selected.has(c.value))
|
|
116
|
+
.map((c) => c.value);
|
|
117
|
+
if (values.length === 0) {
|
|
118
|
+
onResolve({ answer: null, cancelled: true });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
onResolve({ answer: values.join(',') });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}, { isActive: true });
|
|
125
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.choices.map((c, i) => {
|
|
126
|
+
const isCursor = i === cursor;
|
|
127
|
+
const isSelected = selected.has(c.value);
|
|
128
|
+
const mark = isSelected ? '◉' : '○';
|
|
129
|
+
const prefix = isCursor ? '▸ ' : ' ';
|
|
130
|
+
return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [prefix, mark, " ", c.label] }, c.value));
|
|
131
|
+
}) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Space toggle \u00B7 Enter submit \u00B7 Esc cancel" }) })] }));
|
|
132
|
+
}
|
|
@@ -4,16 +4,40 @@ import { Box, Text, useInput } from 'ink';
|
|
|
4
4
|
import { PICK_CANCELLED } from '../skill-picker.js';
|
|
5
5
|
const PEACH = '#FFCBA4';
|
|
6
6
|
/**
|
|
7
|
-
* Ink-mode coaching picker
|
|
8
|
-
* (App) mounts this component only when coachingPending !== null, so the
|
|
9
|
-
* component's useInput being always-active is safe — it can only fire
|
|
10
|
-
* while the overlay is visible.
|
|
7
|
+
* Ink-mode coaching picker — compact single-line-per-option layout.
|
|
11
8
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Phase D3+ (2026-05-16): the prior layout used a bordered Box per
|
|
10
|
+
* option with 3 text rows each (skill+confidence, Does:, Pick if:).
|
|
11
|
+
* For 5 options that's ~25 lines — exceeded available vertical space
|
|
12
|
+
* once mounted inline between Body and Footer, and Ink overflow-
|
|
13
|
+
* clipped only the BOTTOM line of each option leaving a garbled
|
|
14
|
+
* picker (screenshot 71). This rewrite uses one line per option +
|
|
15
|
+
* a "details" line only for the highlighted entry, total height
|
|
16
|
+
* bounded to ~8 lines regardless of option count.
|
|
17
|
+
*
|
|
18
|
+
* Layout (example, 3 options):
|
|
19
|
+
* ╭ Pick a skill for: "What is the best part…?" ───────────╮
|
|
20
|
+
* │ ▸ /abap-explain 72% Plain-language code explainer │
|
|
21
|
+
* │ /abap-review 0% Code review vs quality criteria│
|
|
22
|
+
* │ /abap-document 0% Generate ABAP documentation │
|
|
23
|
+
* │ │
|
|
24
|
+
* │ ▸ /abap-explain — Pick if: pick this to teach code to a human │
|
|
25
|
+
* │ ↑/↓ navigate · Enter select · Esc cancel │
|
|
26
|
+
* ╰──────────────────────────────────────────────────────────╯
|
|
14
27
|
*/
|
|
15
28
|
export function CoachingPicker({ params, onResolve }) {
|
|
16
|
-
|
|
29
|
+
// 2026-05-17 — initial cursor position priority:
|
|
30
|
+
// 1. preferSkill (the just-used skill, if present in options) — lets
|
|
31
|
+
// the user confirm "same skill as last turn" with one Enter press
|
|
32
|
+
// 2. preSelect=true → top-1 (classifier's best guess)
|
|
33
|
+
// 3. -1 → no selection, user must arrow-key to a choice
|
|
34
|
+
const preferIdx = params.preferSkill
|
|
35
|
+
? params.options.findIndex((o) => o.skill === params.preferSkill)
|
|
36
|
+
: -1;
|
|
37
|
+
const initialCursor = preferIdx >= 0
|
|
38
|
+
? preferIdx
|
|
39
|
+
: params.preSelect ? 0 : -1;
|
|
40
|
+
const [cursor, setCursor] = useState(initialCursor);
|
|
17
41
|
useInput((_input, key) => {
|
|
18
42
|
if (key.escape) {
|
|
19
43
|
onResolve(PICK_CANCELLED);
|
|
@@ -37,5 +61,28 @@ export function CoachingPicker({ params, onResolve }) {
|
|
|
37
61
|
onResolve(chosen.skill);
|
|
38
62
|
}
|
|
39
63
|
}, { isActive: true });
|
|
40
|
-
|
|
64
|
+
const promptPreview = params.prompt.length > 60
|
|
65
|
+
? params.prompt.slice(0, 57) + '…'
|
|
66
|
+
: params.prompt;
|
|
67
|
+
const highlighted = cursor >= 0 ? params.options[cursor] : undefined;
|
|
68
|
+
return (
|
|
69
|
+
// flexShrink={0} (2026-05-17) — without this, Ink's default
|
|
70
|
+
// flexShrink: 1 lets the picker get squashed when vertical space is
|
|
71
|
+
// tight, and the TOP options clip off (only the bottom one remained
|
|
72
|
+
// visible — see screenshot 74). flexShrink={0} guarantees the picker
|
|
73
|
+
// claims its natural height; Body shrinks to make room (and Body
|
|
74
|
+
// content stays recoverable via PgUp/Ctrl+B scroll).
|
|
75
|
+
_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsxs(Text, { bold: true, children: ["Pick a skill for: ", _jsxs(Text, { color: "cyan", children: ["\"", promptPreview, "\""] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.options.map((opt, i) => {
|
|
76
|
+
const isCursor = i === cursor;
|
|
77
|
+
const arrow = isCursor ? '▸ ' : ' ';
|
|
78
|
+
// 2026-05-17 — hide the "0%" cruft. Confidence is 0 for any
|
|
79
|
+
// alternate / fallback option (we never had a real score for
|
|
80
|
+
// them), AND for top-1 picks where the classifier itself
|
|
81
|
+
// returned 0. In both cases displaying "0%" looks broken.
|
|
82
|
+
// Show "%" only when there's a meaningful number.
|
|
83
|
+
const pctNum = Math.round(opt.confidence * 100);
|
|
84
|
+
const pct = pctNum > 0 ? `${pctNum.toString().padStart(3, ' ')}%` : ' ';
|
|
85
|
+
const name = `/${opt.skill}`.padEnd(20, ' ');
|
|
86
|
+
return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [arrow, name, " ", pct, " ", opt.whatItDoes.slice(0, 60)] }, opt.skill));
|
|
87
|
+
}) }), highlighted && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Pick if: ", highlighted.whenToPick] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" }) })] }));
|
|
41
88
|
}
|