@cspeach/cli 1.1.21 → 1.1.22

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.
@@ -1,131 +0,0 @@
1
- // cspeach-cli/src/tools/subagent/schedule_create.ts
2
- /**
3
- * schedule_create — Phase 3 / Chunk 3E.
4
- *
5
- * Persists a schedule envelope to ~/.cspeach/schedules/<uuid>.json.
6
- * Filename is ALWAYS crypto.randomUUID — never derived from user
7
- * input. Closes the path-injection class of attack on the schedules
8
- * directory.
9
- *
10
- * IMPORTANT: this tool persists schedules. It does NOT execute them.
11
- * A future chunk (Phase 4 or later) ships the actual runner. The
12
- * tool description and the response body both surface this caveat
13
- * so the agent doesn't assume the schedule will fire.
14
- *
15
- * Flag-gated: invisible to listTools() unless
16
- * CSPEACH_TOOL_SCHEDULE_CREATE=on. isMutating: true (writes filesystem).
17
- */
18
- import { promises as fs } from 'node:fs';
19
- import * as os from 'node:os';
20
- import * as path from 'node:path';
21
- import { randomUUID } from 'node:crypto';
22
- import { registerTool } from '../index.js';
23
- const NOT_EXECUTED_NOTE = 'Persisted only — the schedule runner has not shipped yet. ' +
24
- 'See Phase 4+ for execution.';
25
- // Test-overridable. Default to ~/.cspeach/schedules. Set null to reset.
26
- let schedulesDirOverride = null;
27
- /** Test-only: redirect the schedules dir so tests don't pollute the real home dir. */
28
- export function __setSchedulesDirForTests(dir) {
29
- schedulesDirOverride = dir;
30
- }
31
- function getSchedulesDir() {
32
- return schedulesDirOverride ?? path.join(os.homedir(), '.cspeach', 'schedules');
33
- }
34
- // Strict ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS(.sss)?Z
35
- const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
36
- export async function scheduleCreateHandler(args, _ctx) {
37
- if (args.kind !== 'oneoff' && args.kind !== 'cron') {
38
- return { content: `error: kind must be 'oneoff' or 'cron' — got '${args.kind}'`, is_error: true };
39
- }
40
- if (!args.when || typeof args.when !== 'string' || args.when.trim().length === 0) {
41
- return { content: 'error: when is required (non-empty string)', is_error: true };
42
- }
43
- if (!args.command || typeof args.command !== 'string' || args.command.trim().length === 0) {
44
- return { content: 'error: command is required (non-empty string)', is_error: true };
45
- }
46
- if (args.kind === 'oneoff') {
47
- if (!ISO_PATTERN.test(args.when)) {
48
- return { content: `error: when must be an ISO-8601 UTC timestamp (e.g. 2099-06-01T10:00:00Z) — got '${args.when}'`, is_error: true };
49
- }
50
- const t = Date.parse(args.when);
51
- if (Number.isNaN(t)) {
52
- return { content: `error: when is not a parseable ISO timestamp: '${args.when}'`, is_error: true };
53
- }
54
- // Belt-and-braces against silent month/day rollover. Date.parse('2099-02-30T00:00:00Z')
55
- // returns a valid timestamp that round-trips to March 2 — without this check, the
56
- // envelope persists '2099-02-30' but the runner fires on March 2 (phantom job).
57
- const roundTrip = new Date(t).toISOString();
58
- // Normalise both sides: lowercase 'z', strip optional fractional seconds, compare.
59
- const norm = (s) => s.replace(/\.\d+Z$/, 'Z').toUpperCase();
60
- if (norm(roundTrip) !== norm(args.when)) {
61
- return { content: `error: when is not a valid calendar date: '${args.when}' (rolls to ${roundTrip})`, is_error: true };
62
- }
63
- if (t <= Date.now()) {
64
- return { content: `error: when is in the past — schedule must be in the future`, is_error: true };
65
- }
66
- }
67
- else {
68
- // cron: just check it has 5 whitespace-separated fields.
69
- const fields = args.when.trim().split(/\s+/);
70
- if (fields.length !== 5) {
71
- return { content: `error: cron expression must have 5 fields (min hour dom mon dow) — got ${fields.length}`, is_error: true };
72
- }
73
- }
74
- const dir = getSchedulesDir();
75
- await fs.mkdir(dir, { recursive: true });
76
- // Defence-in-depth: resolve the directory and verify it sits inside
77
- // the cspeach home (or the test override). Refuses if a symlink
78
- // points the dir at /etc/, /tmp/host-controlled/, etc.
79
- const realDir = await fs.realpath(dir);
80
- // In test mode the override dir IS the root (self-containment is trivial —
81
- // mkdtemp gives us a unique dir that acts as both schedules-dir and its own
82
- // root). In prod the schedules dir must sit UNDER ~/.cspeach/, so the root
83
- // is the parent. Asymmetric on purpose.
84
- const expectedRoot = schedulesDirOverride !== null
85
- ? path.resolve(schedulesDirOverride)
86
- : path.join(os.homedir(), '.cspeach');
87
- const rel = path.relative(expectedRoot, realDir);
88
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
89
- return { content: `error: schedules directory '${realDir}' resolves outside expected root`, is_error: true };
90
- }
91
- const id = randomUUID();
92
- const envelope = {
93
- id,
94
- kind: args.kind,
95
- when: args.when,
96
- command: args.command,
97
- createdAt: new Date().toISOString(),
98
- note: NOT_EXECUTED_NOTE,
99
- };
100
- const target = path.join(realDir, `${id}.json`);
101
- await fs.writeFile(target, JSON.stringify(envelope, null, 2), { encoding: 'utf-8', mode: 0o600 });
102
- return {
103
- content: `schedule persisted (NOT YET EXECUTED — runner ships in a later chunk).\n` +
104
- `path: ${target}\n` +
105
- `id: ${id}\n` +
106
- `kind: ${args.kind}\n` +
107
- `when: ${args.when}\n` +
108
- `command: ${args.command}\n` +
109
- `note: ${NOT_EXECUTED_NOTE}`,
110
- };
111
- }
112
- registerTool({
113
- name: 'schedule_create',
114
- description: 'Persist a oneoff or cron schedule envelope to ~/.cspeach/schedules/<uuid>.json. ' +
115
- 'Filename is a fresh UUID — never derived from input. NOTE: this tool only PERSISTS the ' +
116
- 'schedule. No runner is shipped yet — the agent should not assume the command will fire ' +
117
- 'automatically. A future chunk will ship the runner.',
118
- isMutating: true,
119
- category: 'subagent',
120
- flagGated: true,
121
- input_schema: {
122
- type: 'object',
123
- properties: {
124
- kind: { type: 'string', enum: ['oneoff', 'cron'], description: 'oneoff = single ISO timestamp; cron = 5-field expression' },
125
- when: { type: 'string', description: 'ISO-8601 UTC timestamp for oneoff, or 5-field cron expression' },
126
- command: { type: 'string', description: 'The command the runner WOULD execute (free-form string for now)' },
127
- },
128
- required: ['kind', 'when', 'command'],
129
- },
130
- handler: (args, ctx) => scheduleCreateHandler(args, ctx),
131
- });
@@ -1,21 +0,0 @@
1
- import { EventEmitter } from 'node:events';
2
- import { shouldUseInk } from '../renderer/tty.js';
3
- import chalk from 'chalk';
4
- export const errorEmitter = new EventEmitter();
5
- /**
6
- * Emit an error. In Ink mode, the main App subscribes and pushes the error
7
- * into its errorStack state for overlay rendering. In non-TTY / one-shot,
8
- * we fall back to chalk.red on stderr — never mount a second Ink root.
9
- */
10
- export function emitError(err) {
11
- if (shouldUseInk()) {
12
- errorEmitter.emit('error', err);
13
- }
14
- else {
15
- console.error(chalk.red(`[${err.code}] ${err.title}`));
16
- if (err.body)
17
- console.error(chalk.red(` ${err.body}`));
18
- if (err.hint)
19
- console.error(chalk.dim(` ${err.hint}`));
20
- }
21
- }
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
- }
@@ -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();
@@ -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
- }
@@ -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
- }
@@ -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
- }