- system prompt gains a Project workflow block when the repo tracks its own progress (TODO.md/ROADMAP.md/docs): read the task list first and keep it current, spec-first for non-trivial work, complete unit tests, verify before declaring done - TODO.md + ROADMAP.md loaded as 'Project tracker' instruction blocks, capped tighter than AGENTS.md so the agent sees the shape without drowning in it - one soft nudge per session when a turn writes files without touching the task list; suppressed when todo_write was called; never a gate - /workflow command + panel showing TODO/ROADMAP/docs presence, line/file counts, nudge state - config: workflow.enabled (default true), workflow.docsDir (default docs) - prompt-cache version key extended with wf: so toggling the workflow busts the cached system prompt - docs: docs/workflow.md, configuration table row, ROADMAP + TODO entries - tests: test/workflow.test.ts (9 tests) 830 tests pass, typecheck clean, build green, live headless demo verified
258 lines
9.9 KiB
TypeScript
258 lines
9.9 KiB
TypeScript
import type { CustomCommand } from './custom-commands';
|
|
|
|
export type CommandAction =
|
|
| { type: 'none' }
|
|
| { type: 'prompt'; text: string }
|
|
| { type: 'exit' }
|
|
| { type: 'clear' }
|
|
| { type: 'compact' }
|
|
| { type: 'tools' }
|
|
| { type: 'cost' }
|
|
| { type: 'sessions' }
|
|
| { type: 'save' }
|
|
| { type: 'provider' }
|
|
| { type: 'models' }
|
|
| { type: 'init' }
|
|
| { type: 'context' }
|
|
| { type: 'todos' }
|
|
| { type: 'notes' }
|
|
| { type: 'skills' }
|
|
| { type: 'plugins' }
|
|
| { type: 'registry'; action: 'list' | 'search' | 'add' | 'remove' | 'installed'; arg?: string }
|
|
| { type: 'mcp'; action: 'list' | 'add' | 'remove'; arg?: string }
|
|
| { type: 'memory' }
|
|
| { type: 'agent'; agent?: string }
|
|
| { type: 'think'; level?: string }
|
|
| { type: 'info'; text: string }
|
|
| { type: 'model'; model: string }
|
|
| { type: 'resume'; id: string }
|
|
| { type: 'undo' }
|
|
| { type: 'redo' }
|
|
| { type: 'changes' }
|
|
| { type: 'search'; query: string }
|
|
| { type: 'fork' }
|
|
| { type: 'workflow' }
|
|
/** A custom command from a markdown file, expanded against its arguments. */
|
|
| { type: 'custom'; command: CustomCommand; args: string[] }
|
|
| { type: 'unknown'; name: string };
|
|
|
|
export type CommandSpec = {
|
|
name: string;
|
|
/** Extra names that resolve to the same command, hidden from the menu. */
|
|
aliases?: string[];
|
|
arg?: string;
|
|
summary: string;
|
|
};
|
|
|
|
/** Single source of truth for the menu, `/help`, and the parser. */
|
|
export const COMMANDS: CommandSpec[] = [
|
|
{ name: 'help', aliases: ['?'], summary: 'list these commands' },
|
|
{ name: 'agent', arg: '[name]', summary: 'switch agent: default, quick, deep, plan, review' },
|
|
{ name: 'think', arg: '[level]', summary: 'thinking level: off, low, medium, high, max' },
|
|
{ name: 'provider', aliases: ['login'], summary: 'set up a provider: pick, paste API key, choose model' },
|
|
{ name: 'models', summary: 'pick a model from the current provider' },
|
|
{ name: 'model', arg: '<id>', summary: 'switch model by name' },
|
|
{ name: 'skills', summary: 'list loaded skills' },
|
|
{ name: 'plugins', summary: 'list active plugins' },
|
|
{ name: 'registry', arg: '[search|add|remove] [name]', summary: 'browse and install external skills and plugins' },
|
|
{ name: 'mcp', arg: '[add|remove <name>]', summary: 'add a local or remote MCP server, or list them' },
|
|
{ name: 'init', summary: 'have the agent write AGENTS.md for this project' },
|
|
{ name: 'context', summary: 'show which instruction files are loaded' },
|
|
{ name: 'todos', summary: "show the agent's task list" },
|
|
{ name: 'notes', summary: 'show what the agent remembers about this project' },
|
|
{ name: 'memory', summary: 'compact the project memory with the model' },
|
|
{ name: 'tools', summary: 'list available tools' },
|
|
{ name: 'compact', summary: 'replace history with a model-written summary' },
|
|
{ name: 'cost', summary: 'tokens and estimated spend this session' },
|
|
{ name: 'sessions', summary: 'list saved sessions' },
|
|
{ name: 'resume', arg: '<id>', summary: 'load a saved session' },
|
|
{ name: 'save', summary: 'write the session to disk now' },
|
|
{ name: 'undo', summary: 'undo the last turn — restores files and conversation (bash effects are not snapshotted)' },
|
|
{ name: 'redo', summary: 'redo the last undone turn' },
|
|
{ name: 'changes', summary: 'show what the last turn changed on disk' },
|
|
{ name: 'search', arg: '<query>', summary: 'search saved sessions for a phrase' },
|
|
{ name: 'fork', summary: 'fork the session at the last turn boundary (keeps the original)' },
|
|
{ name: 'workflow', summary: 'show project workflow state: TODO/ROADMAP tracking, docs, nudges' },
|
|
{ name: 'clear', summary: 'clear the transcript and history' },
|
|
{ name: 'exit', aliases: ['quit'], summary: 'quit' },
|
|
];
|
|
|
|
const usage = (c: CommandSpec) => `/${c.name}${c.arg ? ` ${c.arg}` : ''}`;
|
|
|
|
export const HELP = [
|
|
...COMMANDS.map((c) => `${usage(c).padEnd(22)} ${c.summary}`),
|
|
'',
|
|
'esc interrupt the running turn and clear the queue',
|
|
'ctrl-c kill the running command, keeping the turn',
|
|
'ctrl-r expand or collapse the reasoning panel',
|
|
'tab complete the highlighted command or file path',
|
|
'up / down recall earlier prompts, or move in an open menu',
|
|
'@ complete a workspace path',
|
|
'',
|
|
'typing during a turn queues the prompt; queued prompts run in order afterwards',
|
|
].join('\n');
|
|
|
|
/**
|
|
* Commands whose name starts with the typed prefix, for the `/` menu.
|
|
* An exact name sorts first so pressing enter on `/model` cannot run `/models`.
|
|
* Aliases stay hidden to keep the list short.
|
|
*/
|
|
export function matchCommands(input: string, custom: readonly CustomCommand[] = []): CommandSpec[] {
|
|
if (!input.startsWith('/')) return [];
|
|
const typed = input.slice(1).toLowerCase();
|
|
if (typed.includes(' ')) return [];
|
|
const customSpecs: CommandSpec[] = custom.map((c) => ({ name: c.name, summary: c.description }));
|
|
const hits = [...COMMANDS, ...customSpecs].filter((c) => c.name.startsWith(typed));
|
|
const exact = hits.findIndex((c) => c.name === typed);
|
|
return exact > 0 ? [hits[exact]!, ...hits.filter((_, i) => i !== exact)] : hits;
|
|
}
|
|
|
|
/** True while the input is a bare command name being typed, so the menu should show. */
|
|
export const isMenuOpen = (input: string) => input.startsWith('/') && !input.includes(' ');
|
|
|
|
/**
|
|
* `/registry [list|installed|search <q>|add <name>|remove <name>]`.
|
|
*
|
|
* A bare `/registry` lists everything. `add` and `remove` need a name, and saying
|
|
* so beats fetching the whole index to then complain.
|
|
*/
|
|
function parseRegistry(arg: string): CommandAction {
|
|
const [verb = '', ...rest] = arg.split(/\s+/).filter(Boolean);
|
|
const name = rest.join(' ').trim();
|
|
|
|
switch (verb) {
|
|
case '':
|
|
case 'list':
|
|
return { type: 'registry', action: 'list' };
|
|
case 'installed':
|
|
return { type: 'registry', action: 'installed' };
|
|
case 'search':
|
|
return name
|
|
? { type: 'registry', action: 'search', arg: name }
|
|
: { type: 'info', text: 'usage: /registry search <query>' };
|
|
case 'add':
|
|
case 'install':
|
|
return name
|
|
? { type: 'registry', action: 'add', arg: name }
|
|
: { type: 'info', text: 'usage: /registry add <name>' };
|
|
case 'remove':
|
|
case 'uninstall':
|
|
return name
|
|
? { type: 'registry', action: 'remove', arg: name }
|
|
: { type: 'info', text: 'usage: /registry remove <name>' };
|
|
default:
|
|
// A bare word is almost always a search, and guessing beats a usage line.
|
|
return { type: 'registry', action: 'search', arg: arg.trim() };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `/mcp [list|add|remove <name>]`.
|
|
*
|
|
* A bare `/mcp` lists what is configured, because that is the question asked most
|
|
* often. `add` opens the wizard rather than taking arguments: a server is a name
|
|
* plus a command or a URL plus optional headers, and a single argument string
|
|
* cannot express that without a syntax nobody remembers.
|
|
*/
|
|
function parseMcp(arg: string): CommandAction {
|
|
const [verb = '', ...rest] = arg.split(/\s+/).filter(Boolean);
|
|
const name = rest.join(' ').trim();
|
|
|
|
switch (verb) {
|
|
case '':
|
|
case 'list':
|
|
return { type: 'mcp', action: 'list' };
|
|
case 'add':
|
|
case 'new':
|
|
return { type: 'mcp', action: 'add' };
|
|
case 'remove':
|
|
case 'rm':
|
|
return name ? { type: 'mcp', action: 'remove', arg: name } : { type: 'info', text: 'usage: /mcp remove <name>' };
|
|
default:
|
|
return { type: 'info', text: 'usage: /mcp [list|add|remove <name>]' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pure parser: no IO, so the TUI and headless mode share one definition.
|
|
*
|
|
* Custom commands are consulted only after every built-in name misses, so a
|
|
* markdown file can add a command but never shadow one that ships with the binary.
|
|
*/
|
|
export function parseCommand(raw: string, custom: readonly CustomCommand[] = []): CommandAction {
|
|
const input = raw.trim();
|
|
if (!input) return { type: 'none' };
|
|
if (!input.startsWith('/')) return { type: 'prompt', text: input };
|
|
|
|
const [name = '', ...rest] = input.slice(1).split(/\s+/);
|
|
const arg = rest.join(' ').trim();
|
|
|
|
switch (name) {
|
|
case 'help':
|
|
case '?':
|
|
return { type: 'info', text: HELP };
|
|
case 'exit':
|
|
case 'quit':
|
|
return { type: 'exit' };
|
|
case 'clear':
|
|
return { type: 'clear' };
|
|
case 'compact':
|
|
return { type: 'compact' };
|
|
case 'tools':
|
|
return { type: 'tools' };
|
|
case 'cost':
|
|
return { type: 'cost' };
|
|
case 'sessions':
|
|
return { type: 'sessions' };
|
|
case 'save':
|
|
return { type: 'save' };
|
|
case 'provider':
|
|
case 'login':
|
|
return { type: 'provider' };
|
|
case 'models':
|
|
return { type: 'models' };
|
|
case 'init':
|
|
return { type: 'init' };
|
|
case 'context':
|
|
return { type: 'context' };
|
|
case 'todos':
|
|
return { type: 'todos' };
|
|
case 'notes':
|
|
return { type: 'notes' };
|
|
case 'skills':
|
|
return { type: 'skills' };
|
|
case 'plugins':
|
|
return { type: 'plugins' };
|
|
case 'registry':
|
|
return parseRegistry(arg);
|
|
case 'mcp':
|
|
return parseMcp(arg);
|
|
case 'memory':
|
|
return { type: 'memory' };
|
|
case 'agent':
|
|
return arg ? { type: 'agent', agent: arg } : { type: 'agent' };
|
|
case 'think':
|
|
return arg ? { type: 'think', level: arg } : { type: 'think' };
|
|
case 'model':
|
|
return arg ? { type: 'model', model: arg } : { type: 'models' };
|
|
case 'resume':
|
|
return arg ? { type: 'resume', id: arg } : { type: 'info', text: 'usage: /resume <session-id>' };
|
|
case 'undo':
|
|
return { type: 'undo' };
|
|
case 'redo':
|
|
return { type: 'redo' };
|
|
case 'changes':
|
|
return { type: 'changes' };
|
|
case 'search':
|
|
return arg ? { type: 'search', query: arg } : { type: 'info', text: 'usage: /search <query>' };
|
|
case 'fork':
|
|
return { type: 'fork' };
|
|
case 'workflow':
|
|
return { type: 'workflow' };
|
|
default: {
|
|
const cmd = custom.find((c) => c.name === name);
|
|
return cmd ? { type: 'custom', command: cmd, args: arg ? arg.split(/\s+/) : [] } : { type: 'unknown', name };
|
|
}
|
|
}
|
|
}
|