@cspeach/cli 1.1.49 → 1.1.51
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/cold-prune.js +1 -1
- package/dist/agent/conversation-prompt.js +52 -0
- package/dist/agent/loop.js +189 -5
- package/dist/agent/oversized-results.js +204 -0
- package/dist/agent/turn-error-ux.js +14 -0
- package/dist/cli.js +1 -1
- package/dist/commands/compact.js +6 -0
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/export-audit.js +26 -3
- package/dist/commands/export-conversation.js +343 -0
- package/dist/commands/help.js +3 -0
- package/dist/commands/plan-boundary.js +5 -0
- package/dist/commands/plan-continue-gate.js +11 -0
- package/dist/commands/plan-continue.js +11 -0
- package/dist/commands/plan-startup.js +5 -1
- package/dist/cost/cost-log.js +4 -0
- package/dist/cost/system-block.js +25 -0
- package/dist/docx/doc-meta.js +9 -1
- package/dist/docx/markdown-to-docx.js +14 -1
- package/dist/lane/app-chain.js +24 -1
- package/dist/models/server-config.js +13 -0
- package/dist/projects/document-save.js +65 -8
- package/dist/projects/document-skills.js +32 -1
- package/dist/projects/workspace.js +6 -3
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/slash-completer.js +4 -0
- package/dist/repl.js +50 -16
- package/dist/session/cli-record.js +165 -0
- package/dist/session/fresh-start.js +19 -0
- package/dist/tools/_filesystem-shared.js +7 -0
- package/dist/tools/cut-markers.js +47 -0
- package/dist/tools/filesystem/extract-document.js +50 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/read-document.js +20 -4
- package/dist/tools/tool-output-read.js +5 -1
- package/dist/tools/write-pipeline.js +5 -0
- package/dist/ui/card-slot.js +18 -1
- package/package.json +121 -121
- package/dist/commands/project.js +0 -301
- package/dist/doctor/checks/zcspeach.js +0 -96
- package/dist/projects/email-template.js +0 -56
- package/dist/projects/merge-cca.js +0 -292
- package/dist/tools/local-files.js +0 -31
- package/dist/tools/project/cca_merge.js +0 -164
- package/dist/tools/subagent/schedule_create.js +0 -131
- package/dist/ui/error-emitter.js +0 -21
- package/dist/ui/header.js +0 -41
- package/dist/ui/ink-classifier-route.js +0 -19
- package/dist/ui/rich-error-box.js +0 -9
- package/dist/ui/sidebar.js +0 -12
- package/dist/ui/status-emitter.js +0 -19
- package/dist/ui/status-line.js +0 -62
- package/dist/ui/status-row.js +0 -38
- package/dist/ui/todo-panel.js +0 -85
- package/dist/ui/widgets/ask-form.js +0 -250
- package/dist/ui/widgets/ask-question-modal.js +0 -178
- package/fiori/zlas-inspection-lr/package.json +0 -26
- package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
- package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
- package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
- package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
- package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
- package/live-check-2026-10-17-1343.txt +0 -41
package/dist/ui/header.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { useSapState } from './sap-state-store.js';
|
|
4
|
-
import { formatSpendSegment } from './status-line.js';
|
|
5
|
-
import { CLI_VERSION } from '../lib/version.js';
|
|
6
|
-
const PEACH = '#FFCBA4';
|
|
7
|
-
/**
|
|
8
|
-
* ⚠ SUPERSEDED (UX Wave 1, Task 4 — 2026-07) by ui/status-line.tsx, the
|
|
9
|
-
* bottom-anchored borderless statusline mounted in App between TurnStatus
|
|
10
|
-
* and Footer. This component is no longer rendered anywhere: Phase D6
|
|
11
|
-
* removed the persistent top Header (inline mode can't pin the frame top),
|
|
12
|
-
* and the statusline took over the SapStateStore display role. Kept for
|
|
13
|
-
* reference, not deleted.
|
|
14
|
-
*
|
|
15
|
-
* Single-line rich header (Phase D3, 2026-05-16) — back to one line per
|
|
16
|
-
* Laeeq's feedback that the two-line variant felt worse. Uses tighter
|
|
17
|
-
* separators and shorter labels so it fits cleanly on typical terminals
|
|
18
|
-
* (100+ cols). On a narrower terminal Ink will wrap rather than truncate,
|
|
19
|
-
* accepted as a degradation case.
|
|
20
|
-
*
|
|
21
|
-
* Layout:
|
|
22
|
-
* 🍑 CSPeach 0.6.5 │ S4H │ S.LAEEQ │ tr none │ 28k tok │ $0.54 │ approval-gated
|
|
23
|
-
*/
|
|
24
|
-
export function Header({ writeMode } = {}) {
|
|
25
|
-
const s = useSapState();
|
|
26
|
-
const isAdvisory = writeMode === 'advisory-only';
|
|
27
|
-
const modeLabel = isAdvisory
|
|
28
|
-
? 'ADVISORY'
|
|
29
|
-
: writeMode === 'approval-gated'
|
|
30
|
-
? 'approval-gated'
|
|
31
|
-
: writeMode === 'auto'
|
|
32
|
-
? 'auto'
|
|
33
|
-
: undefined;
|
|
34
|
-
const modeColor = isAdvisory ? 'yellow' : 'green';
|
|
35
|
-
const tokDisplay = s.tokenUsageToday >= 1000
|
|
36
|
-
? `${(s.tokenUsageToday / 1000).toFixed(1)}k tok`
|
|
37
|
-
: `${s.tokenUsageToday} tok`;
|
|
38
|
-
// Shared with <StatusLine /> so the credits/dollar switch has one home.
|
|
39
|
-
const spendDisplay = formatSpendSegment(s);
|
|
40
|
-
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 })] }))] }));
|
|
41
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect } from 'react';
|
|
3
|
-
import { Box, Text, useInput } from 'ink';
|
|
4
|
-
/**
|
|
5
|
-
* Medium-confidence banner rendered inside the Ink frame. Auto-settles to
|
|
6
|
-
* the classifier's top pick after 800ms, unless the user presses Tab — in
|
|
7
|
-
* which case we open the CommandPalette with alternates.
|
|
8
|
-
*/
|
|
9
|
-
export function InkClassifierRoute({ skill, confidence, onSettle, onOpenPalette }) {
|
|
10
|
-
useEffect(() => {
|
|
11
|
-
const t = setTimeout(onSettle, 800);
|
|
12
|
-
return () => clearTimeout(t);
|
|
13
|
-
}, [onSettle]);
|
|
14
|
-
useInput((_input, key) => {
|
|
15
|
-
if (key.tab)
|
|
16
|
-
onOpenPalette();
|
|
17
|
-
});
|
|
18
|
-
return (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["\\u2504 Suggest /", skill, " (confidence ", Math.round(confidence * 100), "%) \\u2014 Tab to pick"] }) }));
|
|
19
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
function severityColor(s) {
|
|
4
|
-
return s === 'error' ? 'red' : s === 'warning' ? 'yellow' : 'blue';
|
|
5
|
-
}
|
|
6
|
-
export function RichErrorBox({ code, message, hint, severity = 'error' }) {
|
|
7
|
-
const color = severityColor(severity);
|
|
8
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: color, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: color, children: code }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: message }) }), hint && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: hint }) }))] }));
|
|
9
|
-
}
|
package/dist/ui/sidebar.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { useSapState } from './sap-state-store.js';
|
|
4
|
-
export function Sidebar() {
|
|
5
|
-
const s = useSapState();
|
|
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.
|
|
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)" })
|
|
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)" })] })] }));
|
|
12
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
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-line.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Text } from 'ink';
|
|
3
|
-
import { useSapState } from './sap-state-store.js';
|
|
4
|
-
import { formatCredits, CREDITS_PENDING } from '../cost/credits.js';
|
|
5
|
-
/**
|
|
6
|
-
* The spend segment shared by <StatusLine /> and <Header />.
|
|
7
|
-
*
|
|
8
|
-
* Single home for the credits/dollar switch so the two chrome widgets can
|
|
9
|
-
* never drift apart — a drift here is exactly how our COGS would leak onto a
|
|
10
|
-
* managed customer's screen from one of them.
|
|
11
|
-
*/
|
|
12
|
-
export function formatSpendSegment(s) {
|
|
13
|
-
if (!s.creditsMode)
|
|
14
|
-
return `$${s.dollarSpendToday.toFixed(2)}`;
|
|
15
|
-
if (s.creditsPending && s.creditsSessionTotal === 0)
|
|
16
|
-
return CREDITS_PENDING;
|
|
17
|
-
return formatCredits(s.creditsSessionTotal, s.creditsUnitLabel);
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Persistent SAP statusline (UX Wave 1, Task 4) — Header reborn,
|
|
21
|
-
* bottom-anchored. Single borderless line rendered between <TurnStatus />
|
|
22
|
-
* and <Footer /> in the dynamic Ink frame:
|
|
23
|
-
*
|
|
24
|
-
* S4H │ S.LAEEQ │ tr K903417 │ approval-gated │ $0.54
|
|
25
|
-
*
|
|
26
|
-
* Descends from ui/header.tsx (Phase D3) minus the border (3 rows → 1) and
|
|
27
|
-
* the version/🍑 (the startup banner owns those). All fields come from the
|
|
28
|
-
* SapStateStore singleton, so transport creation, spend accrual, and
|
|
29
|
-
* write-mode cycling (Shift+Tab / Alt+M / /mode) re-render it live.
|
|
30
|
-
*
|
|
31
|
-
* The mode chip is omitted until the store's writeMode is seeded at REPL
|
|
32
|
-
* startup (null = pre-seed; only reachable in tests and the first ms of
|
|
33
|
-
* mount). Chip colors: advisory-only yellow / approval-gated green / auto
|
|
34
|
-
* cyan.
|
|
35
|
-
*/
|
|
36
|
-
const MODE_COLORS = {
|
|
37
|
-
'advisory-only': 'yellow',
|
|
38
|
-
'approval-gated': 'green',
|
|
39
|
-
auto: 'cyan',
|
|
40
|
-
};
|
|
41
|
-
export function StatusLine() {
|
|
42
|
-
const s = useSapState();
|
|
43
|
-
// Credits mode (2026-09-16): a managed customer sees what THEY are charged.
|
|
44
|
-
// The segment keeps its meaning — "what this session has cost me" — so it
|
|
45
|
-
// carries the session credit total, not the balance (the balance has its
|
|
46
|
-
// own home in the post-turn receipt and `cspeach doctor`).
|
|
47
|
-
const spendDisplay = formatSpendSegment(s);
|
|
48
|
-
// Wave1 live-fix (Bug 1) — one TRUNCATING <Text> instead of a <Box> of
|
|
49
|
-
// sibling Texts. The statusline lives in the dynamic Ink frame, where a
|
|
50
|
-
// row that soft-wraps in the terminal breaks log-update's logical-line
|
|
51
|
-
// erase accounting and leaks stale rows into scrollback (the live
|
|
52
|
-
// "statusline printed 11×" bug — see text-input.tsx maxDisplayWidth doc
|
|
53
|
-
// for the ink source refs). Nested <Text> children squash into one text
|
|
54
|
-
// node, so wrap="truncate-end" clips the whole row at the terminal
|
|
55
|
-
// width via ANSI-aware cli-truncate — this row can never wrap, whatever
|
|
56
|
-
// the field lengths.
|
|
57
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [s.system
|
|
58
|
-
? _jsx(Text, { color: "cyan", children: s.system })
|
|
59
|
-
: _jsx(Text, { dimColor: true, children: "no system" }), _jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { dimColor: true, children: " \u2502 tr " }), (s.transport ?? s.lastWriteTransport)
|
|
60
|
-
? _jsx(Text, { color: "yellow", children: s.transport ?? s.lastWriteTransport })
|
|
61
|
-
: _jsx(Text, { dimColor: true, children: "none" }), s.writeMode && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: MODE_COLORS[s.writeMode], children: s.writeMode })] })), _jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay })] }));
|
|
62
|
-
}
|
package/dist/ui/status-row.js
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { formatCost } from '../cost/pricing.js';
|
|
4
|
-
import { LARGE_SESSION_THRESHOLD } from '../renderer/status-footer.js';
|
|
5
|
-
/**
|
|
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.
|
|
13
|
-
*/
|
|
14
|
-
export function StatusRow({ data }) {
|
|
15
|
-
if (!data)
|
|
16
|
-
return _jsx(Box, {});
|
|
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"] }))] }));
|
|
38
|
-
}
|
package/dist/ui/todo-panel.js
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
3
|
-
import { Box, Text, useInput } from 'ink';
|
|
4
|
-
/**
|
|
5
|
-
* TodoPanel — UX Wave 2 / Task 5. The Ctrl+T overlay rendering the
|
|
6
|
-
* model-maintained task list (`todo_set` tool → session.todos). House
|
|
7
|
-
* style follows SessionTimeline: bordered box mounted above the Footer
|
|
8
|
-
* by <App>, closes on Esc / q — plus Ctrl+T itself (toggle; App's
|
|
9
|
-
* open-handler goes inactive while the panel is up, so the chord cannot
|
|
10
|
-
* double-fire).
|
|
11
|
-
*
|
|
12
|
-
* Row glyphs: ▣ completed (dim) · ▶ in_progress (peach, bold) ·
|
|
13
|
-
* □ pending. Every row is wrap="truncate-end" — Wave-1 rule: no
|
|
14
|
-
* dynamic-frame row may ever soft-wrap (wrapped rows break Ink's erase
|
|
15
|
-
* accounting and leak stale rows into scrollback).
|
|
16
|
-
*
|
|
17
|
-
* Lists longer than TODO_VISIBLE_ROWS get a scrolling window
|
|
18
|
-
* (CommandPalette pattern): ↑/↓ move the window, hidden counts show as
|
|
19
|
-
* "↑ N more" / "↓ N more". The initial window anchors the live in_progress
|
|
20
|
-
* item ~3 rows from the top so the current step is visible on open.
|
|
21
|
-
* "Live" = the LAST in_progress item in list order (F-L2-3) — see
|
|
22
|
-
* activeTodoIndex below.
|
|
23
|
-
*/
|
|
24
|
-
const PEACH = '#FFCBA4';
|
|
25
|
-
export const TODO_VISIBLE_ROWS = 12;
|
|
26
|
-
/** Pure header-counts formatter, exported for tests. */
|
|
27
|
-
export function formatTodoCounts(todos) {
|
|
28
|
-
const done = todos.filter((t) => t.status === 'completed').length;
|
|
29
|
-
const active = todos.filter((t) => t.status === 'in_progress').length;
|
|
30
|
-
const open = todos.filter((t) => t.status === 'pending').length;
|
|
31
|
-
return `Tasks — ${done} done · ${active} active · ${open} open`;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Index of the LIVE step — the LAST in_progress item in list order, or -1.
|
|
35
|
-
*
|
|
36
|
-
* F-L2-3 (sitting #1): todo_set tolerates several in_progress items (a
|
|
37
|
-
* warning, not an error — todo.ts), and models routinely batch transitions,
|
|
38
|
-
* leaving an earlier item still marked in_progress. List order IS plan
|
|
39
|
-
* order, so the later item is the one actually being worked; picking the
|
|
40
|
-
* FIRST match pinned the ▶ on a step already long finished.
|
|
41
|
-
*/
|
|
42
|
-
export function activeTodoIndex(todos) {
|
|
43
|
-
for (let i = todos.length - 1; i >= 0; i--) {
|
|
44
|
-
if (todos[i].status === 'in_progress')
|
|
45
|
-
return i;
|
|
46
|
-
}
|
|
47
|
-
return -1;
|
|
48
|
-
}
|
|
49
|
-
export function TodoPanel({ todos, onClose }) {
|
|
50
|
-
const maxOffset = Math.max(0, todos.length - TODO_VISIBLE_ROWS);
|
|
51
|
-
const activeIdx = activeTodoIndex(todos);
|
|
52
|
-
// Anchor the initial window so the live in_progress item sits ~3 rows from
|
|
53
|
-
// the top (CommandPalette's cursor rule, with the active item as cursor).
|
|
54
|
-
const [offset, setOffset] = useState(() => {
|
|
55
|
-
if (activeIdx <= 3)
|
|
56
|
-
return 0;
|
|
57
|
-
return Math.min(activeIdx - 3, maxOffset);
|
|
58
|
-
});
|
|
59
|
-
useInput((input, key) => {
|
|
60
|
-
if (key.escape || input === 'q' || (key.ctrl && (input === 't' || input === 'T'))) {
|
|
61
|
-
onClose();
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (key.upArrow)
|
|
65
|
-
setOffset((o) => Math.max(0, o - 1));
|
|
66
|
-
if (key.downArrow)
|
|
67
|
-
setOffset((o) => Math.min(maxOffset, o + 1));
|
|
68
|
-
});
|
|
69
|
-
const clamped = Math.min(offset, maxOffset);
|
|
70
|
-
const visible = todos.slice(clamped, clamped + TODO_VISIBLE_ROWS);
|
|
71
|
-
const below = Math.max(0, todos.length - (clamped + TODO_VISIBLE_ROWS));
|
|
72
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 72, children: [_jsx(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: formatTodoCounts(todos) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [todos.length === 0 && (_jsx(Text, { dimColor: true, children: "(no tasks yet \u2014 the model maintains this list on multi-step work)" })), clamped > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", clamped, " more"] }), visible.map((t, i) => {
|
|
73
|
-
const key = `${clamped + i}`;
|
|
74
|
-
if (t.status === 'completed') {
|
|
75
|
-
return _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u25A3 ", t.text] }, key);
|
|
76
|
-
}
|
|
77
|
-
// Only the LIVE step gets the ▶; a stale earlier in_progress item
|
|
78
|
-
// (batched transition) renders as an ordinary open row, so the
|
|
79
|
-
// panel never shows two arrows or points at finished work.
|
|
80
|
-
if (t.status === 'in_progress' && clamped + i === activeIdx) {
|
|
81
|
-
return _jsxs(Text, { color: PEACH, bold: true, wrap: "truncate-end", children: ["\u25B6 ", t.text] }, key);
|
|
82
|
-
}
|
|
83
|
-
return _jsxs(Text, { wrap: "truncate-end", children: ["\u25A1 ", t.text] }, key);
|
|
84
|
-
}), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [todos.length > TODO_VISIBLE_ROWS ? '↑↓ scroll · ' : '', "Esc/q/Ctrl+T to close"] }) })] }));
|
|
85
|
-
}
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
/**
|
|
3
|
-
* AskForm — UX Wave 2 / Task 2: the multi-question Ink form for
|
|
4
|
-
* ask_question v2 batched requests (the CC-parity flagship interaction).
|
|
5
|
-
*
|
|
6
|
-
* One request = 1..4 questions answered in a single visit. Visual
|
|
7
|
-
* de-clutter pass (2026-07, product-owner review): no border box — the
|
|
8
|
-
* chip row + indentation is the form's identity; the approval modal keeps
|
|
9
|
-
* its border deliberately (danger checkpoint):
|
|
10
|
-
*
|
|
11
|
-
* ✓ env name style ← chips (two-space gaps)
|
|
12
|
-
*
|
|
13
|
-
* Which naming style?
|
|
14
|
-
* Used for every generated object
|
|
15
|
-
* ▸ snake_case lv_/lt_ prefixes
|
|
16
|
-
* CamelCase modern style
|
|
17
|
-
* ✎ Other (type your own)
|
|
18
|
-
* [ Submit ] ← ONLY once all answered
|
|
19
|
-
* Enter pick · Tab next · Esc cancel ← one context-sensitive hint
|
|
20
|
-
*
|
|
21
|
-
* Binding rules (Task-2 brief + de-clutter pass):
|
|
22
|
-
* - Chips: one per question (`header`), two-space gaps (no pipes);
|
|
23
|
-
* current = peach + bold, answered = dim with `✓ ` prefix,
|
|
24
|
-
* unanswered = dim. ONE blank line separates the chip row from the
|
|
25
|
-
* question text. Tab/→ next, Shift+Tab/← previous — all INSIDE the
|
|
26
|
-
* form; App's global handlers (mode-cycle, `?`, `/`, `@`) are already
|
|
27
|
-
* gated off by askQuestionPending.
|
|
28
|
-
* - Options: arrow-key list, label + dim description, CommandPalette-style
|
|
29
|
-
* scroll window; multiSelect ⇒ Space toggles ▣/□, Enter confirms the
|
|
30
|
-
* set; single ⇒ Enter picks. ALWAYS a final `✎ Other (type your own)`
|
|
31
|
-
* row → inline windowed TextInput; a typed answer flags `custom: true`.
|
|
32
|
-
* freeText questions render the input directly (no escape row, and the
|
|
33
|
-
* typed answer is the EXPECTED path — no custom flag, per the Task-1
|
|
34
|
-
* AskFormAnswer contract: custom marks the Other ESCAPE only).
|
|
35
|
-
* - Answering advances to the next UNANSWERED question (wrapping); when
|
|
36
|
-
* none remain, focus lands on [ Submit ]. Enter there resolves ALL
|
|
37
|
-
* answers at once, in question order. Tab back re-answers freely.
|
|
38
|
-
* Single-question forms (n === 1) auto-submit on answer — no extra
|
|
39
|
-
* Enter vs the v1 modal; the Submit affordance is multi-question
|
|
40
|
-
* review only (review round M2).
|
|
41
|
-
* - Esc — DELIBERATE DEVIATION from the brief's "Esc anywhere cancels",
|
|
42
|
-
* reviewer-accepted (review round I1): on an OPTIONED question with
|
|
43
|
-
* the Other input focused, Esc first returns focus to the options
|
|
44
|
-
* list (the typed draft is KEPT in otherDrafts); Esc at options
|
|
45
|
-
* level — and anywhere else, including freeText questions (no inner
|
|
46
|
-
* level) — cancels the WHOLE form → { formId, answers: [],
|
|
47
|
-
* cancelled: true } (v1 cancel contract shape per Task 1).
|
|
48
|
-
* - Dynamic-frame safety (Wave-1 lesson): every row truncates (NOTHING
|
|
49
|
-
* may wrap), options scroll, total height ≤ MAX_FORM_ROWS.
|
|
50
|
-
* - No React state keyed on formId (collision caveat from review) — all
|
|
51
|
-
* per-question state lives in arrays keyed by question INDEX within
|
|
52
|
-
* this request object.
|
|
53
|
-
*/
|
|
54
|
-
import { useState } from 'react';
|
|
55
|
-
import { Box, Text, useInput } from 'ink';
|
|
56
|
-
// Wave-1 windowed input — display can never wrap the row (D29 paste-safe).
|
|
57
|
-
import TextInput from '../text-input.js';
|
|
58
|
-
import { askHelpLine, useReclaimRawStdin } from './ask-question-modal.js';
|
|
59
|
-
const PEACH = '#FFCBA4';
|
|
60
|
-
/** Option rows visible at once — the rest scroll (CommandPalette style). */
|
|
61
|
-
export const VISIBLE_OPTION_ROWS = 5;
|
|
62
|
-
/**
|
|
63
|
-
* Hard cap on total rendered rows — pinned by test. Re-derived for the
|
|
64
|
-
* de-clutter pass (border gone = −2 rows; blank chip-gap row = +1; the
|
|
65
|
-
* Submit row only renders once all questions are answered).
|
|
66
|
-
*
|
|
67
|
-
* Worst case — all answered, user Tabbed BACK into a scrolled optioned
|
|
68
|
-
* question for review (Submit row visible below it):
|
|
69
|
-
* chips + blank + question + context + "↑ more" + VISIBLE_OPTION_ROWS
|
|
70
|
-
* + "↓ more" + Other + Submit + help = 14.
|
|
71
|
-
* Pre-completion the Submit row is absent → 13.
|
|
72
|
-
*/
|
|
73
|
-
export const MAX_FORM_ROWS = 14;
|
|
74
|
-
/** Display window for the Other/freeText input — the row must never wrap. */
|
|
75
|
-
const OTHER_INPUT_WIDTH = 50;
|
|
76
|
-
export function AskForm({ request, onResolve }) {
|
|
77
|
-
useReclaimRawStdin();
|
|
78
|
-
const questions = request.questions;
|
|
79
|
-
const n = questions.length;
|
|
80
|
-
// Focus slots: 0..n-1 = questions, n = the [ Submit ] row (reachable
|
|
81
|
-
// only once every question has an answer).
|
|
82
|
-
const [focus, setFocus] = useState(0);
|
|
83
|
-
// Per-question state, keyed by question INDEX (never formId): option
|
|
84
|
-
// cursor (0..options.length — the last row is Other), multi-select
|
|
85
|
-
// sets, Other draft text, and the committed answer.
|
|
86
|
-
const [cursors, setCursors] = useState(() => questions.map(() => 0));
|
|
87
|
-
const [multiSel, setMultiSel] = useState(() => questions.map(() => new Set()));
|
|
88
|
-
const [otherDrafts, setOtherDrafts] = useState(() => questions.map(() => ''));
|
|
89
|
-
const [answers, setAnswers] = useState(() => questions.map(() => null));
|
|
90
|
-
const isSubmit = focus === n;
|
|
91
|
-
const q = isSubmit ? undefined : questions[focus];
|
|
92
|
-
const otherRow = q ? q.options.length : 0;
|
|
93
|
-
const cursor = isSubmit ? 0 : cursors[focus];
|
|
94
|
-
// The inline TextInput owns typing whenever the question is freeText or
|
|
95
|
-
// the cursor sits on the Other row. While it does, the form-level
|
|
96
|
-
// handler must NOT interpret space/Enter/←/→ (Ink delivers every key to
|
|
97
|
-
// every active useInput — there is no stopPropagation).
|
|
98
|
-
const otherFocused = q != null && (q.freeText || cursor === otherRow);
|
|
99
|
-
const allAnswered = answers.every((a) => a !== null);
|
|
100
|
-
const cancel = () => onResolve({ formId: request.formId, answers: [], cancelled: true });
|
|
101
|
-
const submit = (finalAnswers) => onResolve({
|
|
102
|
-
formId: request.formId,
|
|
103
|
-
// Question order, always — regardless of the order the user
|
|
104
|
-
// actually answered in.
|
|
105
|
-
answers: questions.map((qq, i) => ({
|
|
106
|
-
id: qq.id,
|
|
107
|
-
answer: finalAnswers[i]?.answer ?? null,
|
|
108
|
-
...(finalAnswers[i]?.custom ? { custom: true } : {}),
|
|
109
|
-
})),
|
|
110
|
-
});
|
|
111
|
-
/**
|
|
112
|
-
* Commit an answer, then advance to the next UNANSWERED question
|
|
113
|
-
* (wrapping past the end); none left → focus [ Submit ] — except
|
|
114
|
-
* single-question forms, which resolve immediately (M2: no extra Enter
|
|
115
|
-
* vs the v1 modal).
|
|
116
|
-
*/
|
|
117
|
-
const answerAndAdvance = (i, value, custom) => {
|
|
118
|
-
const next = answers.map((a, j) => j === i ? { answer: value, ...(custom ? { custom: true } : {}) } : a);
|
|
119
|
-
for (let step = 1; step <= n; step++) {
|
|
120
|
-
const j = (i + step) % n;
|
|
121
|
-
if (next[j] === null) {
|
|
122
|
-
setAnswers(next);
|
|
123
|
-
setFocus(j);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (n === 1) {
|
|
128
|
-
submit(next);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
setAnswers(next);
|
|
132
|
-
setFocus(n);
|
|
133
|
-
};
|
|
134
|
-
useInput((input, key) => {
|
|
135
|
-
if (key.escape) {
|
|
136
|
-
// Two-level Esc (I1, documented deviation — see file header): the
|
|
137
|
-
// Other input focused on an OPTIONED question → back to the options
|
|
138
|
-
// list, draft kept. freeText has no inner level; the options level
|
|
139
|
-
// cancels the whole form.
|
|
140
|
-
if (q && !q.freeText && otherFocused) {
|
|
141
|
-
setCursors((prev) => prev.map((c, j) => (j === focus ? otherRow - 1 : c)));
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
cancel();
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
// Chip navigation. Tab/Shift+Tab always work (TextInput ignores tab);
|
|
148
|
-
// ←/→ only while the inline text input doesn't own horizontal cursor
|
|
149
|
-
// movement.
|
|
150
|
-
const maxSlot = allAnswered ? n : n - 1;
|
|
151
|
-
if ((key.tab && !key.shift) || (!otherFocused && key.rightArrow)) {
|
|
152
|
-
setFocus((f) => (f >= maxSlot ? 0 : f + 1));
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
if ((key.tab && key.shift) || (!otherFocused && key.leftArrow)) {
|
|
156
|
-
setFocus((f) => (f <= 0 ? maxSlot : f - 1));
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (isSubmit) {
|
|
160
|
-
if (key.return && allAnswered)
|
|
161
|
-
submit(answers);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
if (!q)
|
|
165
|
-
return;
|
|
166
|
-
// ↑/↓ move the option cursor — also FROM the Other row back into the
|
|
167
|
-
// list (TextInput ignores vertical arrows, so no conflict).
|
|
168
|
-
if (!q.freeText) {
|
|
169
|
-
if (key.upArrow) {
|
|
170
|
-
setCursors((prev) => prev.map((c, j) => (j === focus ? Math.max(0, c - 1) : c)));
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
if (key.downArrow) {
|
|
174
|
-
setCursors((prev) => prev.map((c, j) => (j === focus ? Math.min(otherRow, c + 1) : c)));
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// Everything below is typing territory while the Other input has focus.
|
|
179
|
-
if (otherFocused)
|
|
180
|
-
return;
|
|
181
|
-
if (q.multiSelect && input === ' ') {
|
|
182
|
-
const opt = q.options[cursor];
|
|
183
|
-
if (opt) {
|
|
184
|
-
setMultiSel((prev) => prev.map((s, j) => {
|
|
185
|
-
if (j !== focus)
|
|
186
|
-
return s;
|
|
187
|
-
const nextSet = new Set(s);
|
|
188
|
-
if (nextSet.has(opt.value))
|
|
189
|
-
nextSet.delete(opt.value);
|
|
190
|
-
else
|
|
191
|
-
nextSet.add(opt.value);
|
|
192
|
-
return nextSet;
|
|
193
|
-
}));
|
|
194
|
-
}
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
if (key.return) {
|
|
198
|
-
if (q.multiSelect) {
|
|
199
|
-
const values = q.options
|
|
200
|
-
.filter((o) => multiSel[focus].has(o.value))
|
|
201
|
-
.map((o) => o.value);
|
|
202
|
-
if (values.length === 0) {
|
|
203
|
-
// 2026-09-17 — nothing toggled: Enter used to do NOTHING, silently.
|
|
204
|
-
// Every other question answers on Enter, so a user who arrows to a
|
|
205
|
-
// row and presses Enter (Glenn's first session) sat there stuck.
|
|
206
|
-
// Enter alone now picks the highlighted option, single-choice style.
|
|
207
|
-
const opt = q.options[cursor];
|
|
208
|
-
if (opt)
|
|
209
|
-
answerAndAdvance(focus, opt.value);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
answerAndAdvance(focus, values.join(','));
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
const opt = q.options[cursor];
|
|
216
|
-
if (opt)
|
|
217
|
-
answerAndAdvance(focus, opt.value);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}, { isActive: true });
|
|
221
|
-
// CommandPalette-style scroll window over the options (the Other row is
|
|
222
|
-
// rendered separately and is always visible).
|
|
223
|
-
const opts = q?.options ?? [];
|
|
224
|
-
const winStart = Math.max(0, Math.min(cursor - 3, opts.length - VISIBLE_OPTION_ROWS));
|
|
225
|
-
const visible = opts.slice(winStart, winStart + VISIBLE_OPTION_ROWS);
|
|
226
|
-
const below = Math.max(0, opts.length - (winStart + VISIBLE_OPTION_ROWS));
|
|
227
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "truncate-end", children: questions.map((qq, i) => {
|
|
228
|
-
const cur = i === focus;
|
|
229
|
-
const done = answers[i] !== null;
|
|
230
|
-
return (_jsxs(Text, { color: cur ? PEACH : undefined, bold: cur, dimColor: !cur, children: [i > 0 ? ' ' : '', done ? '✓ ' : '', qq.header ?? qq.id] }, qq.id));
|
|
231
|
-
}) }) }), isSubmit ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "All questions answered \u2014 Tab/\u2190 to review, Enter to submit \u00B7 Esc cancel" })) : q ? (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [q.question, q.multiSelect ? ' (choose one or more)' : ''] }), q.context && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: q.context }), !q.freeText && (_jsxs(_Fragment, { children: [winStart > 0 && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u2191 ", winStart, " more"] }), visible.map((o, vi) => {
|
|
232
|
-
const idx = winStart + vi;
|
|
233
|
-
const isCur = !otherFocused && idx === cursor;
|
|
234
|
-
const mark = q.multiSelect
|
|
235
|
-
? (multiSel[focus].has(o.value) ? '▣ ' : '□ ')
|
|
236
|
-
: '';
|
|
237
|
-
// v1 never renders the `recommended` flag — the convention
|
|
238
|
-
// is skills writing "(recommended)" INTO the label. Render
|
|
239
|
-
// the same dim convention only when the label lacks it.
|
|
240
|
-
const rec = o.recommended === true && !/\brecommended\b/i.test(o.label);
|
|
241
|
-
return (_jsxs(Text, { wrap: "truncate-end", color: isCur ? PEACH : undefined, bold: isCur, children: [isCur ? '▸ ' : ' ', mark, o.label, rec && _jsx(Text, { dimColor: true, children: " (recommended)" }), o.description && _jsxs(Text, { dimColor: true, children: [" ", o.description] })] }, o.value));
|
|
242
|
-
}), below > 0 && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u2193 ", below, " more"] })] })), _jsxs(Box, { children: [q.freeText ? (_jsx(Text, { color: PEACH, wrap: "truncate-end", children: '> ' })) : (_jsxs(Text, { color: otherFocused ? PEACH : undefined, bold: otherFocused, wrap: "truncate-end", children: [otherFocused ? '▸ ' : ' ', "\u270E Other (type your own)", otherFocused ? ': ' : ''] })), otherFocused && (_jsx(TextInput, { value: otherDrafts[focus], onChange: (v) => setOtherDrafts((prev) => prev.map((d, j) => (j === focus ? v : d))), onSubmit: (v) => {
|
|
243
|
-
const trimmed = v.trim();
|
|
244
|
-
if (trimmed.length === 0)
|
|
245
|
-
return; // nothing typed — ignore
|
|
246
|
-
// custom marks the Other ESCAPE on an optioned question;
|
|
247
|
-
// freeText typing is the expected path (Task-1 contract).
|
|
248
|
-
answerAndAdvance(focus, trimmed, !q.freeText);
|
|
249
|
-
}, maxDisplayWidth: OTHER_INPUT_WIDTH }))] })] })) : null, allAnswered && (_jsxs(Text, { wrap: "truncate-end", color: isSubmit ? PEACH : undefined, bold: isSubmit, dimColor: !isSubmit, children: [isSubmit ? '▸ ' : ' ', "[ Submit ]"] })), q && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: askHelpLine(q.freeText ? 'freeText' : q.multiSelect ? 'multi' : 'pick', { single: n === 1 }) }))] }));
|
|
250
|
-
}
|