ralphctl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/bin/ralphctl +13 -0
- package/package.json +92 -0
- package/schemas/config.schema.json +20 -0
- package/schemas/ideate-output.schema.json +22 -0
- package/schemas/projects.schema.json +53 -0
- package/schemas/requirements-output.schema.json +24 -0
- package/schemas/sprint.schema.json +109 -0
- package/schemas/task-import.schema.json +49 -0
- package/schemas/tasks.schema.json +72 -0
- package/src/ai/executor.ts +973 -0
- package/src/ai/lifecycle.ts +45 -0
- package/src/ai/parser.ts +40 -0
- package/src/ai/permissions.ts +207 -0
- package/src/ai/process-manager.ts +248 -0
- package/src/ai/prompts/ideate-auto.md +144 -0
- package/src/ai/prompts/ideate.md +165 -0
- package/src/ai/prompts/index.ts +89 -0
- package/src/ai/prompts/plan-auto.md +131 -0
- package/src/ai/prompts/plan-common.md +157 -0
- package/src/ai/prompts/plan-interactive.md +190 -0
- package/src/ai/prompts/task-execution.md +159 -0
- package/src/ai/prompts/ticket-refine.md +230 -0
- package/src/ai/rate-limiter.ts +89 -0
- package/src/ai/runner.ts +478 -0
- package/src/ai/session.ts +319 -0
- package/src/ai/task-context.ts +270 -0
- package/src/cli-metadata.ts +7 -0
- package/src/cli.ts +65 -0
- package/src/commands/completion/index.ts +33 -0
- package/src/commands/config/config.ts +58 -0
- package/src/commands/config/index.ts +33 -0
- package/src/commands/dashboard/dashboard.ts +5 -0
- package/src/commands/dashboard/index.ts +6 -0
- package/src/commands/doctor/doctor.ts +271 -0
- package/src/commands/doctor/index.ts +25 -0
- package/src/commands/progress/index.ts +25 -0
- package/src/commands/progress/log.ts +64 -0
- package/src/commands/progress/show.ts +14 -0
- package/src/commands/project/add.ts +336 -0
- package/src/commands/project/index.ts +104 -0
- package/src/commands/project/list.ts +31 -0
- package/src/commands/project/remove.ts +43 -0
- package/src/commands/project/repo.ts +118 -0
- package/src/commands/project/show.ts +49 -0
- package/src/commands/sprint/close.ts +180 -0
- package/src/commands/sprint/context.ts +109 -0
- package/src/commands/sprint/create.ts +60 -0
- package/src/commands/sprint/current.ts +75 -0
- package/src/commands/sprint/delete.ts +72 -0
- package/src/commands/sprint/health.ts +229 -0
- package/src/commands/sprint/ideate.ts +496 -0
- package/src/commands/sprint/index.ts +226 -0
- package/src/commands/sprint/list.ts +86 -0
- package/src/commands/sprint/plan-utils.ts +207 -0
- package/src/commands/sprint/plan.ts +549 -0
- package/src/commands/sprint/refine.ts +359 -0
- package/src/commands/sprint/requirements.ts +58 -0
- package/src/commands/sprint/show.ts +140 -0
- package/src/commands/sprint/start.ts +119 -0
- package/src/commands/sprint/switch.ts +20 -0
- package/src/commands/task/add.ts +316 -0
- package/src/commands/task/import.ts +150 -0
- package/src/commands/task/index.ts +123 -0
- package/src/commands/task/list.ts +145 -0
- package/src/commands/task/next.ts +45 -0
- package/src/commands/task/remove.ts +47 -0
- package/src/commands/task/reorder.ts +45 -0
- package/src/commands/task/show.ts +111 -0
- package/src/commands/task/status.ts +99 -0
- package/src/commands/ticket/add.ts +265 -0
- package/src/commands/ticket/edit.ts +166 -0
- package/src/commands/ticket/index.ts +114 -0
- package/src/commands/ticket/list.ts +128 -0
- package/src/commands/ticket/refine-utils.ts +89 -0
- package/src/commands/ticket/refine.ts +268 -0
- package/src/commands/ticket/remove.ts +48 -0
- package/src/commands/ticket/show.ts +74 -0
- package/src/completion/handle.ts +30 -0
- package/src/completion/resolver.ts +241 -0
- package/src/interactive/dashboard.ts +268 -0
- package/src/interactive/escapable.ts +81 -0
- package/src/interactive/file-browser.ts +153 -0
- package/src/interactive/index.ts +429 -0
- package/src/interactive/menu.ts +403 -0
- package/src/interactive/selectors.ts +273 -0
- package/src/interactive/wizard.ts +221 -0
- package/src/providers/claude.ts +53 -0
- package/src/providers/copilot.ts +86 -0
- package/src/providers/index.ts +43 -0
- package/src/providers/types.ts +85 -0
- package/src/schemas/index.ts +130 -0
- package/src/store/config.ts +74 -0
- package/src/store/progress.ts +230 -0
- package/src/store/project.ts +276 -0
- package/src/store/sprint.ts +229 -0
- package/src/store/task.ts +443 -0
- package/src/store/ticket.ts +178 -0
- package/src/theme/index.ts +215 -0
- package/src/theme/ui.ts +872 -0
- package/src/utils/detect-scripts.ts +247 -0
- package/src/utils/editor-input.ts +41 -0
- package/src/utils/editor.ts +37 -0
- package/src/utils/exit-codes.ts +27 -0
- package/src/utils/file-lock.ts +135 -0
- package/src/utils/git.ts +185 -0
- package/src/utils/ids.ts +37 -0
- package/src/utils/issue-fetch.ts +244 -0
- package/src/utils/json-extract.ts +62 -0
- package/src/utils/multiline.ts +61 -0
- package/src/utils/path-selector.ts +236 -0
- package/src/utils/paths.ts +108 -0
- package/src/utils/provider.ts +34 -0
- package/src/utils/requirements-export.ts +63 -0
- package/src/utils/storage.ts +107 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { CompletionItem } from 'tabtab';
|
|
3
|
+
|
|
4
|
+
export interface CompletionContext {
|
|
5
|
+
/** The full line typed so far */
|
|
6
|
+
line: string;
|
|
7
|
+
/** The last word (what the user is currently typing) */
|
|
8
|
+
last: string;
|
|
9
|
+
/** The word before the last word */
|
|
10
|
+
prev: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type DynamicResolver = () => Promise<CompletionItem[]>;
|
|
14
|
+
|
|
15
|
+
const dynamicResolvers: Record<string, DynamicResolver> = {
|
|
16
|
+
'--project': async () => {
|
|
17
|
+
try {
|
|
18
|
+
const { listProjects } = await import('@src/store/project.ts');
|
|
19
|
+
const projects = await listProjects();
|
|
20
|
+
return projects.map((p) => ({ name: p.name, description: p.displayName }));
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
'--status': () => {
|
|
26
|
+
// Context-dependent but we return all possible values — shell filtering handles partial match
|
|
27
|
+
return Promise.resolve([
|
|
28
|
+
{ name: 'draft', description: 'Draft sprints' },
|
|
29
|
+
{ name: 'active', description: 'Active sprints' },
|
|
30
|
+
{ name: 'closed', description: 'Closed sprints' },
|
|
31
|
+
{ name: 'todo', description: 'Todo tasks' },
|
|
32
|
+
{ name: 'in_progress', description: 'In-progress tasks' },
|
|
33
|
+
{ name: 'done', description: 'Done tasks' },
|
|
34
|
+
{ name: 'pending', description: 'Pending requirements' },
|
|
35
|
+
{ name: 'approved', description: 'Approved requirements' },
|
|
36
|
+
]);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the value completions for `config set <key>` and `config set <key> <value>`.
|
|
42
|
+
*/
|
|
43
|
+
const configKeyCompletions: CompletionItem[] = [
|
|
44
|
+
{ name: 'provider', description: 'AI provider (claude or copilot)' },
|
|
45
|
+
{ name: 'editor', description: 'External editor for multiline input' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const configValueCompletions: Record<string, CompletionItem[]> = {
|
|
49
|
+
provider: [
|
|
50
|
+
{ name: 'claude', description: 'Claude Code CLI' },
|
|
51
|
+
{ name: 'copilot', description: 'GitHub Copilot CLI' },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Try to load sprint IDs for positional completion.
|
|
57
|
+
*/
|
|
58
|
+
async function getSprintCompletions(): Promise<CompletionItem[]> {
|
|
59
|
+
try {
|
|
60
|
+
const { listSprints } = await import('@src/store/sprint.ts');
|
|
61
|
+
const sprints = await listSprints();
|
|
62
|
+
return sprints.map((s) => ({
|
|
63
|
+
name: s.id,
|
|
64
|
+
description: `${s.name} (${s.status})`,
|
|
65
|
+
}));
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get all commands from a Commander command (direct children).
|
|
73
|
+
*/
|
|
74
|
+
function getSubcommands(cmd: Command): CompletionItem[] {
|
|
75
|
+
return cmd.commands.map((sub: Command) => ({
|
|
76
|
+
name: sub.name(),
|
|
77
|
+
description: sub.description(),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get all options from a Commander command.
|
|
83
|
+
*/
|
|
84
|
+
function getOptions(cmd: Command): CompletionItem[] {
|
|
85
|
+
const items: CompletionItem[] = [];
|
|
86
|
+
for (const opt of cmd.options) {
|
|
87
|
+
// Prefer long flag, fall back to short
|
|
88
|
+
const flag = opt.long ?? opt.short;
|
|
89
|
+
if (flag) {
|
|
90
|
+
items.push({ name: flag, description: opt.description });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return items;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find a subcommand by name.
|
|
98
|
+
*/
|
|
99
|
+
function findSubcommand(cmd: Command, name: string): Command | undefined {
|
|
100
|
+
return cmd.commands.find((sub: Command) => sub.name() === name);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check whether the given option expects a value argument (not boolean).
|
|
105
|
+
*/
|
|
106
|
+
function optionExpectsValue(cmd: Command, flag: string): boolean {
|
|
107
|
+
const opt = cmd.options.find((o) => o.long === flag || o.short === flag);
|
|
108
|
+
if (!opt) return false;
|
|
109
|
+
// Commander: boolean flags (--force) have required=false and optional=false
|
|
110
|
+
return opt.required || opt.optional;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse words out of the completion line (skipping the program name).
|
|
115
|
+
*/
|
|
116
|
+
function parseWords(line: string): string[] {
|
|
117
|
+
return line.trim().split(/\s+/).slice(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve completions for the current input context by introspecting a Commander program.
|
|
122
|
+
*/
|
|
123
|
+
export async function resolveCompletions(program: Command, ctx: CompletionContext): Promise<CompletionItem[]> {
|
|
124
|
+
const words = parseWords(ctx.line);
|
|
125
|
+
|
|
126
|
+
// Walk the Commander tree to find the deepest matching command
|
|
127
|
+
let currentCmd = program;
|
|
128
|
+
let wordIndex = 0;
|
|
129
|
+
|
|
130
|
+
while (wordIndex < words.length) {
|
|
131
|
+
const word = words[wordIndex];
|
|
132
|
+
if (!word) break;
|
|
133
|
+
|
|
134
|
+
// Skip flags and their values during traversal
|
|
135
|
+
if (word.startsWith('-')) {
|
|
136
|
+
wordIndex++;
|
|
137
|
+
// If the flag expects a value, skip the next word too
|
|
138
|
+
if (optionExpectsValue(currentCmd, word) && wordIndex < words.length) {
|
|
139
|
+
wordIndex++;
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const sub = findSubcommand(currentCmd, word);
|
|
145
|
+
if (sub) {
|
|
146
|
+
currentCmd = sub;
|
|
147
|
+
wordIndex++;
|
|
148
|
+
} else {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Special case: `config set` positional args
|
|
154
|
+
const cmdPath = getCommandPath(currentCmd);
|
|
155
|
+
if (cmdPath === 'config set') {
|
|
156
|
+
return resolveConfigSetCompletions(words, ctx);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// If the previous word is a flag that expects a value → resolve that value
|
|
160
|
+
if (ctx.prev.startsWith('-')) {
|
|
161
|
+
// Check for dynamic resolver
|
|
162
|
+
const resolver = dynamicResolvers[ctx.prev];
|
|
163
|
+
if (resolver) {
|
|
164
|
+
return resolver();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// If the option expects a value but we have no resolver, return empty (let user type)
|
|
168
|
+
if (optionExpectsValue(currentCmd, ctx.prev)) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// If the user is typing a flag (starts with -)
|
|
174
|
+
if (ctx.last.startsWith('-')) {
|
|
175
|
+
return getOptions(currentCmd);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// If the command has subcommands, offer those
|
|
179
|
+
const subs = getSubcommands(currentCmd);
|
|
180
|
+
if (subs.length > 0) {
|
|
181
|
+
return subs;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// For commands that accept a positional [id] argument (sprint subcommands), offer sprint IDs
|
|
185
|
+
if (cmdPath.startsWith('sprint ') && acceptsPositionalArg(currentCmd)) {
|
|
186
|
+
return getSprintCompletions();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get the full command path (e.g. "sprint start", "config set").
|
|
194
|
+
*/
|
|
195
|
+
function getCommandPath(cmd: Command): string {
|
|
196
|
+
const parts: string[] = [];
|
|
197
|
+
let current: Command | null = cmd;
|
|
198
|
+
while (current?.parent) {
|
|
199
|
+
parts.unshift(current.name());
|
|
200
|
+
current = current.parent as Command | null;
|
|
201
|
+
}
|
|
202
|
+
return parts.join(' ');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check whether a command accepts positional arguments (has [id] or similar in usage).
|
|
207
|
+
*/
|
|
208
|
+
function acceptsPositionalArg(cmd: Command): boolean {
|
|
209
|
+
// Commander stores registered args
|
|
210
|
+
return cmd.registeredArguments.length > 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Handle completions for `config set <key> [value]`.
|
|
215
|
+
*/
|
|
216
|
+
function resolveConfigSetCompletions(words: string[], ctx: CompletionContext): Promise<CompletionItem[]> {
|
|
217
|
+
const setIndex = words.indexOf('set');
|
|
218
|
+
|
|
219
|
+
if (setIndex === -1) {
|
|
220
|
+
return Promise.resolve(configKeyCompletions);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Positional args after "set" (excluding flags)
|
|
224
|
+
const argsAfterSet = words.slice(setIndex + 1).filter((w) => !w.startsWith('-'));
|
|
225
|
+
|
|
226
|
+
// No args yet, or typing a partial key → suggest keys
|
|
227
|
+
if (argsAfterSet.length === 0) {
|
|
228
|
+
return Promise.resolve(configKeyCompletions);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// One arg present and user is still typing it (last word matches the arg) → suggest keys
|
|
232
|
+
if (argsAfterSet.length === 1 && ctx.last !== '') {
|
|
233
|
+
return Promise.resolve(configKeyCompletions);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// One arg present and cursor moved past it (last is empty) → suggest values for that key
|
|
237
|
+
// OR two args present (user is typing the value) → suggest values for the first arg
|
|
238
|
+
const key = argsAfterSet[0];
|
|
239
|
+
const values = key ? configValueCompletions[key] : undefined;
|
|
240
|
+
return Promise.resolve(values ?? []);
|
|
241
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { getAiProvider, getCurrentSprint } from '@src/store/config.ts';
|
|
2
|
+
import { getSprint } from '@src/store/sprint.ts';
|
|
3
|
+
import { getTasks } from '@src/store/task.ts';
|
|
4
|
+
import { getPendingRequirements } from '@src/store/ticket.ts';
|
|
5
|
+
import { colors, getQuoteForContext } from '@src/theme/index.ts';
|
|
6
|
+
import { boxChars, emoji, formatSprintStatus, icons, progressBar } from '@src/theme/ui.ts';
|
|
7
|
+
import type { Sprint, Tasks } from '@src/schemas/index.ts';
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// STATUS DASHBOARD
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
export interface DashboardData {
|
|
14
|
+
sprint: Sprint;
|
|
15
|
+
tasks: Tasks;
|
|
16
|
+
approvedCount: number;
|
|
17
|
+
pendingCount: number;
|
|
18
|
+
blockedCount: number;
|
|
19
|
+
/** Number of tickets that have at least one associated task */
|
|
20
|
+
plannedTicketCount: number;
|
|
21
|
+
/** Current AI provider setting */
|
|
22
|
+
aiProvider: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load dashboard data from the current sprint.
|
|
27
|
+
* Returns null if no current sprint is set.
|
|
28
|
+
*/
|
|
29
|
+
export async function loadDashboardData(): Promise<DashboardData | null> {
|
|
30
|
+
const sprintId = await getCurrentSprint();
|
|
31
|
+
if (!sprintId) return null;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const sprint = await getSprint(sprintId);
|
|
35
|
+
const tasks = await getTasks(sprintId);
|
|
36
|
+
|
|
37
|
+
const pendingTickets = getPendingRequirements(sprint.tickets);
|
|
38
|
+
const pendingCount = pendingTickets.length;
|
|
39
|
+
const approvedCount = sprint.tickets.length - pendingCount;
|
|
40
|
+
|
|
41
|
+
// Count tasks that are blocked (not done, and have unresolved blockers)
|
|
42
|
+
const doneIds = new Set(tasks.filter((t) => t.status === 'done').map((t) => t.id));
|
|
43
|
+
const blockedCount = tasks.filter(
|
|
44
|
+
(t) => t.status !== 'done' && t.blockedBy.length > 0 && !t.blockedBy.every((id) => doneIds.has(id))
|
|
45
|
+
).length;
|
|
46
|
+
|
|
47
|
+
// Count tickets that have at least one associated task
|
|
48
|
+
const ticketIdsWithTasks = new Set(tasks.map((t) => t.ticketId).filter(Boolean));
|
|
49
|
+
const plannedTicketCount = sprint.tickets.filter((t) => ticketIdsWithTasks.has(t.id)).length;
|
|
50
|
+
|
|
51
|
+
const aiProvider = await getAiProvider();
|
|
52
|
+
|
|
53
|
+
return { sprint, tasks, approvedCount, pendingCount, blockedCount, plannedTicketCount, aiProvider };
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface NextAction {
|
|
60
|
+
label: string;
|
|
61
|
+
description: string;
|
|
62
|
+
group: string;
|
|
63
|
+
subCommand: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Determine the suggested next action based on sprint state.
|
|
68
|
+
*/
|
|
69
|
+
export function getNextAction(data: DashboardData): NextAction | null {
|
|
70
|
+
const { sprint, tasks, pendingCount, approvedCount } = data;
|
|
71
|
+
const ticketCount = sprint.tickets.length;
|
|
72
|
+
const totalTasks = tasks.length;
|
|
73
|
+
const allDone = totalTasks > 0 && tasks.every((t) => t.status === 'done');
|
|
74
|
+
|
|
75
|
+
if (sprint.status === 'draft') {
|
|
76
|
+
if (ticketCount === 0) {
|
|
77
|
+
return { label: 'Add Ticket', description: 'No tickets yet', group: 'ticket', subCommand: 'add' };
|
|
78
|
+
}
|
|
79
|
+
if (pendingCount > 0) {
|
|
80
|
+
return {
|
|
81
|
+
label: 'Refine Requirements',
|
|
82
|
+
description: `${String(pendingCount)} ticket${pendingCount !== 1 ? 's' : ''} pending`,
|
|
83
|
+
group: 'sprint',
|
|
84
|
+
subCommand: 'refine',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (approvedCount > 0 && totalTasks === 0) {
|
|
88
|
+
return { label: 'Plan Tasks', description: 'Requirements approved', group: 'sprint', subCommand: 'plan' };
|
|
89
|
+
}
|
|
90
|
+
// Draft with tasks but unplanned tickets — suggest re-plan
|
|
91
|
+
if (totalTasks > 0 && data.plannedTicketCount < ticketCount) {
|
|
92
|
+
const unplanned = ticketCount - data.plannedTicketCount;
|
|
93
|
+
return {
|
|
94
|
+
label: 'Re-Plan Tasks',
|
|
95
|
+
description: `${String(unplanned)} unplanned ticket${unplanned !== 1 ? 's' : ''}`,
|
|
96
|
+
group: 'sprint',
|
|
97
|
+
subCommand: 'plan',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (totalTasks > 0) {
|
|
101
|
+
return {
|
|
102
|
+
label: 'Start Sprint',
|
|
103
|
+
description: `${String(totalTasks)} task${totalTasks !== 1 ? 's' : ''} ready`,
|
|
104
|
+
group: 'sprint',
|
|
105
|
+
subCommand: 'start',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (sprint.status === 'active') {
|
|
111
|
+
if (allDone) {
|
|
112
|
+
return { label: 'Close Sprint', description: 'All tasks done', group: 'sprint', subCommand: 'close' };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
label: 'Continue Work',
|
|
116
|
+
description: `${String(totalTasks - tasks.filter((t) => t.status === 'done').length)} task${totalTasks - tasks.filter((t) => t.status === 'done').length !== 1 ? 's' : ''} remaining`,
|
|
117
|
+
group: 'sprint',
|
|
118
|
+
subCommand: 'start',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render a compact 2-3 line status header for display above the main menu.
|
|
127
|
+
* Returns an array of lines, or empty array if no data.
|
|
128
|
+
*/
|
|
129
|
+
export function renderStatusHeader(data: DashboardData | null): string[] {
|
|
130
|
+
if (!data) return [];
|
|
131
|
+
|
|
132
|
+
const { sprint, tasks, approvedCount, aiProvider } = data;
|
|
133
|
+
const totalTasks = tasks.length;
|
|
134
|
+
const ticketCount = sprint.tickets.length;
|
|
135
|
+
|
|
136
|
+
const lines: string[] = [];
|
|
137
|
+
|
|
138
|
+
// Line 1: sprint name, status, counts, provider
|
|
139
|
+
const sprintLabel = colors.highlight(sprint.name);
|
|
140
|
+
const statusBadge = formatSprintStatus(sprint.status);
|
|
141
|
+
const ticketPart = `${String(ticketCount)} ticket${ticketCount !== 1 ? 's' : ''}`;
|
|
142
|
+
const taskPart = `${String(totalTasks)} task${totalTasks !== 1 ? 's' : ''}`;
|
|
143
|
+
const providerPart = aiProvider === 'claude' ? 'Claude' : aiProvider === 'copilot' ? 'Copilot' : null;
|
|
144
|
+
const providerSuffix = providerPart ? ` | ${providerPart}` : '';
|
|
145
|
+
lines.push(
|
|
146
|
+
` ${icons.sprint} ${sprintLabel} ${statusBadge} ${colors.muted(`| ${ticketPart} | ${taskPart}${providerSuffix}`)}`
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Line 2: task progress (active/closed) or refined/planned counts (draft)
|
|
150
|
+
if ((sprint.status === 'active' || sprint.status === 'closed') && totalTasks > 0) {
|
|
151
|
+
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
|
152
|
+
const bar = progressBar(doneCount, totalTasks, { width: 15 });
|
|
153
|
+
const inProgressCount = tasks.filter((t) => t.status === 'in_progress').length;
|
|
154
|
+
const todoCount = tasks.filter((t) => t.status === 'todo').length;
|
|
155
|
+
lines.push(
|
|
156
|
+
` ${bar} ${colors.muted(`${String(doneCount)} done, ${String(inProgressCount)} active, ${String(todoCount)} todo`)}`
|
|
157
|
+
);
|
|
158
|
+
} else if (sprint.status === 'draft' && ticketCount > 0) {
|
|
159
|
+
const refinedColor = approvedCount === ticketCount ? colors.success : colors.warning;
|
|
160
|
+
const refinedPart = refinedColor(`Refined: ${String(approvedCount)}/${String(ticketCount)}`);
|
|
161
|
+
const plannedColor = data.plannedTicketCount === ticketCount ? colors.success : colors.muted;
|
|
162
|
+
const plannedPart = plannedColor(`Planned: ${String(data.plannedTicketCount)}/${String(ticketCount)}`);
|
|
163
|
+
lines.push(` ${refinedPart} ${colors.muted('|')} ${plannedPart}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Render the status dashboard showing current sprint info and task progress.
|
|
171
|
+
* Returns an array of lines to display.
|
|
172
|
+
*/
|
|
173
|
+
function renderDashboard(data: DashboardData): string[] {
|
|
174
|
+
const { sprint, tasks, approvedCount, blockedCount } = data;
|
|
175
|
+
const chars = boxChars.rounded;
|
|
176
|
+
|
|
177
|
+
const todoCount = tasks.filter((t) => t.status === 'todo').length;
|
|
178
|
+
const inProgressCount = tasks.filter((t) => t.status === 'in_progress').length;
|
|
179
|
+
const doneCount = tasks.filter((t) => t.status === 'done').length;
|
|
180
|
+
const totalTasks = tasks.length;
|
|
181
|
+
const ticketCount = sprint.tickets.length;
|
|
182
|
+
|
|
183
|
+
// Build content lines
|
|
184
|
+
const lines: string[] = [];
|
|
185
|
+
|
|
186
|
+
// Sprint info line
|
|
187
|
+
const sprintLabel = colors.highlight(sprint.name);
|
|
188
|
+
const statusBadge = formatSprintStatus(sprint.status);
|
|
189
|
+
lines.push(` ${icons.sprint} ${sprintLabel} ${statusBadge}`);
|
|
190
|
+
|
|
191
|
+
// Tickets & tasks summary
|
|
192
|
+
const ticketSummary = `${String(ticketCount)} ticket${ticketCount !== 1 ? 's' : ''}`;
|
|
193
|
+
const taskSummary = `${String(totalTasks)} task${totalTasks !== 1 ? 's' : ''}`;
|
|
194
|
+
lines.push(` ${colors.muted(`${ticketSummary} ${chars.vertical} ${taskSummary}`)}`);
|
|
195
|
+
|
|
196
|
+
// Task progress bar
|
|
197
|
+
if (totalTasks > 0) {
|
|
198
|
+
const bar = progressBar(doneCount, totalTasks);
|
|
199
|
+
const detail = colors.muted(
|
|
200
|
+
`${String(doneCount)} done, ${String(inProgressCount)} active, ${String(todoCount)} todo`
|
|
201
|
+
);
|
|
202
|
+
lines.push(` ${bar} ${detail}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Ticket requirement status (draft only — not relevant for active/closed)
|
|
206
|
+
if (sprint.status === 'draft' && ticketCount > 0) {
|
|
207
|
+
const refinedColor = approvedCount === ticketCount ? colors.success : colors.warning;
|
|
208
|
+
const refinedPart = refinedColor(`Refined: ${String(approvedCount)}/${String(ticketCount)}`);
|
|
209
|
+
const plannedColor = data.plannedTicketCount === ticketCount ? colors.success : colors.muted;
|
|
210
|
+
const plannedPart = plannedColor(`Planned: ${String(data.plannedTicketCount)}/${String(ticketCount)}`);
|
|
211
|
+
lines.push(` ${refinedPart} ${colors.muted('|')} ${plannedPart}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Blocked task alerts
|
|
215
|
+
if (blockedCount > 0) {
|
|
216
|
+
lines.push(
|
|
217
|
+
` ${colors.warning(icons.warning)} ${colors.warning(`${String(blockedCount)} blocked task${blockedCount !== 1 ? 's' : ''}`)}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Next action suggestion
|
|
222
|
+
const nextAction = getNextAction(data);
|
|
223
|
+
if (nextAction) {
|
|
224
|
+
lines.push(
|
|
225
|
+
` ${colors.muted(icons.tip)} ${colors.muted(nextAction.label + ':')} ${colors.highlight(nextAction.description)}`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Render a friendly empty state when no sprint exists.
|
|
234
|
+
*/
|
|
235
|
+
function renderEmptyDashboard(): string[] {
|
|
236
|
+
const quote = getQuoteForContext('idle');
|
|
237
|
+
return [
|
|
238
|
+
` ${emoji.donut} ${colors.muted('No current sprint')}`,
|
|
239
|
+
` ${colors.muted(`"${quote}"`)}`,
|
|
240
|
+
'',
|
|
241
|
+
` ${colors.muted(icons.tip)} ${colors.muted('Get started:')}`,
|
|
242
|
+
` ${colors.muted('1.')} ${colors.muted('Add a project:')} ${colors.highlight('ralphctl project add')}`,
|
|
243
|
+
` ${colors.muted('2.')} ${colors.muted('Create a sprint:')} ${colors.highlight('ralphctl sprint create')}`,
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Display the status dashboard.
|
|
249
|
+
* Shows current sprint info, task progress, and recent activity.
|
|
250
|
+
* Falls back to a friendly empty state when no sprint exists.
|
|
251
|
+
*/
|
|
252
|
+
export async function showDashboard(): Promise<void> {
|
|
253
|
+
const data = await loadDashboardData();
|
|
254
|
+
|
|
255
|
+
console.log('');
|
|
256
|
+
if (data) {
|
|
257
|
+
const lines = renderDashboard(data);
|
|
258
|
+
for (const line of lines) {
|
|
259
|
+
console.log(line);
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
const lines = renderEmptyDashboard();
|
|
263
|
+
for (const line of lines) {
|
|
264
|
+
console.log(line);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
console.log('');
|
|
268
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { select } from '@inquirer/prompts';
|
|
3
|
+
import { bold, dim } from 'colorette';
|
|
4
|
+
|
|
5
|
+
type SelectConfig<Value> = Parameters<typeof select<Value>>[0];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default keysHelpTip renderer matching @inquirer/select's built-in format.
|
|
9
|
+
* Used as fallback when the caller doesn't provide a custom keysHelpTip.
|
|
10
|
+
*/
|
|
11
|
+
function defaultKeysHelpTip(keys: [string, string][]): string {
|
|
12
|
+
return keys.map(([key, action]) => `${bold(key)} ${dim(action)}`).join(dim(' \u2022 '));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Augment a select config's theme to append an "esc back" hint to the key help line.
|
|
17
|
+
*/
|
|
18
|
+
type KeysHelpTip = (keys: [string, string][]) => string | undefined;
|
|
19
|
+
|
|
20
|
+
function withEscapeHint<Value>(config: SelectConfig<Value>, escLabel = 'back'): SelectConfig<Value> {
|
|
21
|
+
const originalTip = config.theme?.style?.keysHelpTip as KeysHelpTip | undefined;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
...config,
|
|
25
|
+
theme: {
|
|
26
|
+
...config.theme,
|
|
27
|
+
style: {
|
|
28
|
+
...config.theme?.style,
|
|
29
|
+
keysHelpTip: (keys: [string, string][]) => {
|
|
30
|
+
const allKeys: [string, string][] = [...keys, ['esc', escLabel]];
|
|
31
|
+
return originalTip ? originalTip(allKeys) : defaultKeysHelpTip(allKeys);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Escape-aware wrapper around @inquirer/prompts select().
|
|
40
|
+
*
|
|
41
|
+
* Listens for the Escape key and aborts the prompt, returning null.
|
|
42
|
+
* Ctrl+C (ExitPromptError) propagates unchanged so callers can handle it.
|
|
43
|
+
* Appends an "esc <label>" hint to the bottom help line.
|
|
44
|
+
*
|
|
45
|
+
* Uses duck-typing for AbortPromptError to avoid depending on @inquirer/core directly.
|
|
46
|
+
*
|
|
47
|
+
* @param config - select prompt config
|
|
48
|
+
* @param options.escLabel - hint label shown next to "esc" (default: "back")
|
|
49
|
+
* @returns the selected value, or null if the user pressed Escape
|
|
50
|
+
*/
|
|
51
|
+
export async function escapableSelect<Value>(
|
|
52
|
+
config: SelectConfig<Value>,
|
|
53
|
+
options?: { escLabel?: string }
|
|
54
|
+
): Promise<Value | null> {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
|
|
57
|
+
// Ensure stdin emits 'keypress' events (idempotent — safe to call multiple times)
|
|
58
|
+
readline.emitKeypressEvents(process.stdin);
|
|
59
|
+
|
|
60
|
+
const onKeypress = (_ch: string, key: { name: string } | undefined) => {
|
|
61
|
+
if (key?.name === 'escape') {
|
|
62
|
+
controller.abort();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
process.stdin.on('keypress', onKeypress);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const result = await select(withEscapeHint(config, options?.escLabel ?? 'back'), {
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
});
|
|
72
|
+
return result;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err instanceof Error && err.name === 'AbortPromptError') {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
} finally {
|
|
79
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
80
|
+
}
|
|
81
|
+
}
|