diff --git a/.worklog/plugins/ampa.mjs b/.worklog/plugins/ampa.mjs index be95e2a..641f0f4 100644 --- a/.worklog/plugins/ampa.mjs +++ b/.worklog/plugins/ampa.mjs @@ -14,6 +14,85 @@ import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; import fsPromises from 'fs/promises'; import path from 'path'; +import os from 'os'; + +/** + * Resolve the global OpenCode base directory. + * Returns ${XDG_CONFIG_HOME}/opencode or $HOME/.config/opencode. + * Throws if neither XDG_CONFIG_HOME nor HOME is available. + */ +function globalOpenCodeDir() { + const xdg = process.env.XDG_CONFIG_HOME; + if (xdg) { + return path.join(xdg, 'opencode'); + } + const home = process.env.HOME || os.homedir(); + if (!home) { + throw new Error( + 'Cannot determine global directory: neither XDG_CONFIG_HOME nor HOME is set' + ); + } + return path.join(home, '.config', 'opencode'); +} + +/** + * Resolve the global AMPA data directory. + * Pool state files (pool-state.json, pool-cleanup.json, pool-replenish.log) + * are stored here so they are shared across all projects. + * + * Respects XDG_CONFIG_HOME; falls back to $HOME/.config. + * Throws if neither XDG_CONFIG_HOME nor HOME is set. + */ +function globalAmpaDir() { + return path.join(globalOpenCodeDir(), '.worklog', 'ampa'); +} + +/** + * Resolve the global plugins directory. + * Returns ${XDG_CONFIG_HOME}/opencode/.worklog/plugins or + * $HOME/.config/opencode/.worklog/plugins. + */ +function globalPluginsDir() { + return path.join(globalOpenCodeDir(), '.worklog', 'plugins'); +} + +/** + * Resolve the per-project AMPA config directory. + * Per-project configuration (.env, scheduler_store.json) and daemon runtime + * files (PID, log) are stored here. + * + * Path: /.worklog/ampa/ + */ +function projectAmpaDir(projectRoot) { + return path.join(projectRoot, '.worklog', 'ampa'); +} + +/** + * Locate the bundled AMPA Python package (ampa_py). + * + * Resolution order: + * 1. /.worklog/plugins/ampa_py/ (local override) + * 2. /ampa_py/ (global install) + * + * Returns { pyPath, pythonBin } or null if the package is not found in + * either location. + */ +function resolveAmpaPackage(projectRoot) { + const locations = [ + path.join(projectRoot, '.worklog', 'plugins', 'ampa_py'), + path.join(globalPluginsDir(), 'ampa_py'), + ]; + for (const pyPath of locations) { + try { + if (fs.existsSync(path.join(pyPath, 'ampa', '__init__.py'))) { + const venvPython = path.join(pyPath, 'venv', 'bin', 'python'); + const pythonBin = fs.existsSync(venvPython) ? venvPython : 'python3'; + return { pyPath, pythonBin }; + } + } catch (e) {} + } + return null; +} function findProjectRoot(start) { let cur = path.resolve(start); @@ -78,10 +157,10 @@ function readDotEnv(projectRoot, extraPaths = []) { async function resolveCommand(cliCmd, projectRoot) { if (cliCmd) return Array.isArray(cliCmd) ? cliCmd : shellSplit(cliCmd); if (process.env.WL_AMPA_CMD) return shellSplit(process.env.WL_AMPA_CMD); - const wl = path.join(projectRoot, 'worklog.json'); - if (fs.existsSync(wl)) { + const wlJson = path.join(projectRoot, 'worklog.json'); + if (fs.existsSync(wlJson)) { try { - const data = JSON.parse(await fsPromises.readFile(wl, 'utf8')); + const data = JSON.parse(await fsPromises.readFile(wlJson, 'utf8')); if (data && typeof data === 'object' && 'ampa' in data) { const val = data.ampa; if (typeof val === 'string') return shellSplit(val); @@ -89,10 +168,10 @@ async function resolveCommand(cliCmd, projectRoot) { } } catch (e) {} } - const pkg = path.join(projectRoot, 'package.json'); - if (fs.existsSync(pkg)) { + const pkgJson = path.join(projectRoot, 'package.json'); + if (fs.existsSync(pkgJson)) { try { - const pj = JSON.parse(await fsPromises.readFile(pkg, 'utf8')); + const pj = JSON.parse(await fsPromises.readFile(pkgJson, 'utf8')); const scripts = pj.scripts || {}; if (scripts.ampa) return shellSplit(scripts.ampa); } catch (e) {} @@ -103,73 +182,77 @@ async function resolveCommand(cliCmd, projectRoot) { if (fs.existsSync(c) && fs.accessSync(c, fs.constants.X_OK) === undefined) return [c]; } catch (e) {} } - // Fallback: if a bundled Python package 'ampa' was installed into - // .worklog/plugins/ampa_py/ampa, prefer running it with Python -m ampa.daemon - try { - const pyBundle = path.join(projectRoot, '.worklog', 'plugins', 'ampa_py', 'ampa'); - if (fs.existsSync(path.join(pyBundle, '__init__.py'))) { - const pyPath = path.join(projectRoot, '.worklog', 'plugins', 'ampa_py'); - const venvPython = path.join(pyPath, 'venv', 'bin', 'python'); - const pythonBin = fs.existsSync(venvPython) ? venvPython : 'python3'; - const launcher = `import sys; sys.path.insert(0, ${JSON.stringify(pyPath)}); import ampa.daemon as d; d.main()`; - // Run the daemon in long-running mode by default (start scheduler). - // Users can override via --cmd or AMPA_RUN_SCHEDULER env var if desired. - // use -u to force unbuffered stdout/stderr so logs show up promptly - return { - cmd: [pythonBin, '-u', '-c', launcher, '--start-scheduler'], - env: { PYTHONPATH: pyPath, AMPA_RUN_SCHEDULER: '1' }, - }; - } - } catch (e) {} + // Fallback: look for the bundled Python package 'ampa' in the local or global + // plugins directory. resolveAmpaPackage() checks project-local first, then + // the global install path — there is no fallback to a development source tree. + const pkg = resolveAmpaPackage(projectRoot); + if (pkg) { + const { pyPath, pythonBin } = pkg; + const launcher = `import sys; sys.path.insert(0, ${JSON.stringify(pyPath)}); import ampa.daemon as d; d.main()`; + // Run the daemon in long-running mode by default (start scheduler). + // Users can override via --cmd or AMPA_RUN_SCHEDULER env var if desired. + // use -u to force unbuffered stdout/stderr so logs show up promptly + return { + cmd: [pythonBin, '-u', '-c', launcher, '--start-scheduler'], + env: { PYTHONPATH: pyPath, AMPA_RUN_SCHEDULER: '1' }, + }; + } return null; } -async function resolveRunOnceCommand(projectRoot, commandId) { - if (!commandId) return null; - // Prefer bundled python package if available. - try { - const pyBundle = path.join(projectRoot, '.worklog', 'plugins', 'ampa_py', 'ampa'); - if (fs.existsSync(path.join(pyBundle, '__init__.py'))) { - const pyPath = path.join(projectRoot, '.worklog', 'plugins', 'ampa_py'); - const venvPython = path.join(pyPath, 'venv', 'bin', 'python'); - const pythonBin = fs.existsSync(venvPython) ? venvPython : 'python3'; - return { - cmd: [pythonBin, '-u', '-m', 'ampa.scheduler', 'run-once', commandId], - env: { PYTHONPATH: pyPath }, - envPaths: [path.join(pyPath, 'ampa', '.env')], - }; - } - } catch (e) {} - // Fallback to repo/local package - return { - cmd: ['python3', '-m', 'ampa.scheduler', 'run-once', commandId], - env: {}, - envPaths: [path.join(projectRoot, 'ampa', '.env')], - }; +async function resolveRunOnceCommand(projectRoot, commandId, extraArgs = []) { + // Build base args: use 'run' subcommand (enhanced version). + const subcommand = 'run'; + const baseArgs = ['-u', '-m', 'ampa.scheduler_cli', subcommand]; + if (commandId) baseArgs.push(commandId); + if (extraArgs.length) baseArgs.push(...extraArgs); + + // Per-project config: .env is always loaded from /.worklog/ampa/.env + const envPath = path.join(projectAmpaDir(projectRoot), '.env'); + + // Locate the bundled Python package (local override or global install). + const pkg = resolveAmpaPackage(projectRoot); + if (pkg) { + const { pyPath, pythonBin } = pkg; + return { + cmd: [pythonBin, ...baseArgs], + env: { PYTHONPATH: pyPath }, + envPaths: [envPath], + }; + } + // No bundled package found — report error instead of falling back to dev tree. + throw new Error( + 'AMPA Python package not found. Install the plugin with:\n' + + ' skill/install-ampa/scripts/install-worklog-plugin.sh --yes\n' + + 'Expected at: /.worklog/plugins/ampa_py/ or ' + + globalPluginsDir() + '/ampa_py/' + ); } async function resolveListCommand(projectRoot, useJson) { - const args = ['-m', 'ampa.scheduler', 'list']; + const args = ['-m', 'ampa.scheduler_cli', 'list']; if (useJson) args.push('--json'); - // Prefer bundled python package if available. - try { - const pyBundle = path.join(projectRoot, '.worklog', 'plugins', 'ampa_py', 'ampa'); - if (fs.existsSync(path.join(pyBundle, '__init__.py'))) { - const pyPath = path.join(projectRoot, '.worklog', 'plugins', 'ampa_py'); - const venvPython = path.join(pyPath, 'venv', 'bin', 'python'); - const pythonBin = fs.existsSync(venvPython) ? venvPython : 'python3'; - return { - cmd: [pythonBin, '-u', ...args], - env: { PYTHONPATH: pyPath }, - envPaths: [path.join(pyPath, 'ampa', '.env')], - }; - } - } catch (e) {} - return { - cmd: ['python3', '-u', ...args], - env: {}, - envPaths: [path.join(projectRoot, 'ampa', '.env')], - }; + + // Per-project config: .env is always loaded from /.worklog/ampa/.env + const envPath = path.join(projectAmpaDir(projectRoot), '.env'); + + // Locate the bundled Python package (local override or global install). + const pkg = resolveAmpaPackage(projectRoot); + if (pkg) { + const { pyPath, pythonBin } = pkg; + return { + cmd: [pythonBin, '-u', ...args], + env: { PYTHONPATH: pyPath }, + envPaths: [envPath], + }; + } + // No bundled package found — report error instead of falling back to dev tree. + throw new Error( + 'AMPA Python package not found. Install the plugin with:\n' + + ' skill/install-ampa/scripts/install-worklog-plugin.sh --yes\n' + + 'Expected at: /.worklog/plugins/ampa_py/ or ' + + globalPluginsDir() + '/ampa_py/' + ); } const DAEMON_NOT_RUNNING_MESSAGE = 'Daemon is not running. Start it with: wl ampa start'; @@ -210,25 +293,70 @@ function resolveDaemonStore(projectRoot, name = 'default') { cwd = fs.readlinkSync(`/proc/${pid}/cwd`); } catch (e) {} const env = readDaemonEnv(pid) || {}; + + // Resolution order (matches the Python daemon's SchedulerConfig.from_env): + // 1. AMPA_SCHEDULER_STORE env var (explicit override) + // 2. /.worklog/ampa/scheduler_store.json (per-project) + // 3. /ampa/scheduler_store.json (backward compat) let storePath = env.AMPA_SCHEDULER_STORE || ''; if (!storePath) { - const candidates = []; - if (env.PYTHONPATH) { - for (const entry of env.PYTHONPATH.split(path.delimiter)) { - if (entry) candidates.push(entry); + // Per-project path (preferred) + const projectStore = path.join(projectAmpaDir(projectRoot), 'scheduler_store.json'); + if (fs.existsSync(projectStore)) { + storePath = projectStore; + } else { + // Backward compat: look inside the Python package directory. Prefer the + // per-project package first, then any PYTHONPATH entries, then the + // global plugins dir. If a package provides a scheduler_store.json use + // that; otherwise continue searching. If no package store is found, + // default to the per-project path. + const candidates = []; + // per-project package candidate first + candidates.push(path.join(projectRoot, '.worklog', 'plugins', 'ampa_py')); + // Do not consult the daemon's PYTHONPATH here — the test harness and + // developer environments may set PYTHONPATH to include local copies of + // the package, which would break test isolation. Only consider the + // per-project package and the daemon's XDG_CONFIG_HOME-based global + // plugins path. + // Determine the global plugins dir based on the daemon's environment + // (env) when possible. Tests spawn the daemon with XDG_CONFIG_HOME set + // in its environment; prefer that so resolution matches what the daemon + // will see. Fall back to the current process's globalPluginsDir() + // only if the daemon didn't set XDG_CONFIG_HOME. + try { + // Only consult the daemon's XDG_CONFIG_HOME (from the daemon process + // environment). Do NOT consult this process's XDG_CONFIG_HOME — that + // risks picking up the developer's installed plugin and breaking test + // isolation. + if (env.XDG_CONFIG_HOME) { + const gbase = path.join(env.XDG_CONFIG_HOME, 'opencode', '.worklog', 'plugins'); + candidates.push(path.join(gbase, 'ampa_py')); + } + } catch (e) {} + + const perProjectPkg = path.join(projectRoot, '.worklog', 'plugins', 'ampa_py'); + for (const candidate of candidates) { + const ampaPath = path.join(candidate, 'ampa'); + if (!fs.existsSync(path.join(ampaPath, 'scheduler.py'))) continue; + const pkgStore = path.join(ampaPath, 'scheduler_store.json'); + if (fs.existsSync(pkgStore)) { + storePath = pkgStore; + break; + } + // If the per-project package exists but has no store file, prefer the + // per-project scheduler_store.json path rather than falling back to + // a global package store. This keeps per-project isolation intact. + if (candidate === perProjectPkg) { + storePath = projectStore; + break; + } + // otherwise continue searching other candidates } - } - candidates.push(path.join(projectRoot, '.worklog', 'plugins', 'ampa_py')); - for (const candidate of candidates) { - const ampaPath = path.join(candidate, 'ampa'); - if (fs.existsSync(path.join(ampaPath, 'scheduler.py'))) { - storePath = path.join(ampaPath, 'scheduler_store.json'); - break; + // Default to per-project path even if file doesn't exist yet + if (!storePath) { + storePath = projectStore; } } - } - if (!storePath) { - storePath = path.join(cwd, 'ampa', 'scheduler_store.json'); } else if (!path.isAbsolute(storePath)) { storePath = path.resolve(cwd, storePath); } @@ -327,7 +455,7 @@ function readLogTail(lpath, maxBytes = 64 * 1024) { function extractErrorLines(text) { if (!text) return []; const lines = text.split(/\r?\n/); - const re = /(ERROR|Traceback|Exception|AMPA_DISCORD_WEBHOOK)/i; + const re = /(ERROR|Traceback|Exception|AMPA_DISCORD_BOT_TOKEN|AMPA_DISCORD_CHANNEL_ID)/i; const out = []; for (const l of lines) { if (re.test(l)) out.push(l); @@ -909,9 +1037,13 @@ function poolContainerName(index) { * Path to the pool state JSON file. * Stores a mapping of pool container name -> { workItemId, branch, claimedAt } * for containers that have been claimed by start-work. + * + * Pool state is stored in the global AMPA directory so it is shared across + * all projects. The projectRoot parameter is accepted for API compatibility + * but is no longer used for path resolution. */ -function poolStatePath(projectRoot) { - return path.join(projectRoot, '.worklog', 'ampa', 'pool-state.json'); +function poolStatePath(_projectRoot) { + return path.join(globalAmpaDir(), 'pool-state.json'); } /** @@ -1007,9 +1139,13 @@ function releasePoolContainer(projectRoot, containerNameOrAll) { /** * Path to the pool cleanup JSON file. * Stores an array of container names that should be destroyed from the host. + * + * Pool cleanup state is stored in the global AMPA directory so it is shared + * across all projects. The projectRoot parameter is accepted for API + * compatibility but is no longer used for path resolution. */ -function poolCleanupPath(projectRoot) { - return path.join(projectRoot, '.worklog', 'ampa', 'pool-cleanup.json'); +function poolCleanupPath(_projectRoot) { + return path.join(globalAmpaDir(), 'pool-cleanup.json'); } /** @@ -1214,7 +1350,9 @@ function replenishPoolBackground(projectRoot) { `.catch(e => { process.stderr.write(String(e) + '\\n'); process.exit(1); });`, ].join(''); - const logFile = path.join(projectRoot, '.worklog', 'ampa', 'pool-replenish.log'); + const logDir = globalAmpaDir(); + fs.mkdirSync(logDir, { recursive: true }); + const logFile = path.join(logDir, 'pool-replenish.log'); const out = fs.openSync(logFile, 'a'); try { fs.appendFileSync(logFile, `\n--- replenish started at ${new Date().toISOString()} ---\n`); @@ -1593,6 +1731,10 @@ async function finishWork(force = false, workItemIdArg) { const insideContainer = !!process.env.AMPA_CONTAINER_NAME; let cName, workItemId, branch, projectRoot; + // commitHash: set when we push from inside the container + let commitHash = null; + // commitHashHost: set when we push via host-enter path + let commitHashHost = null; if (insideContainer) { // Inside-container path: read env vars set by start-work @@ -1695,35 +1837,57 @@ async function finishWork(force = false, workItemIdArg) { runSync('wl', ['sync']); const hashResult = runSync('git', ['rev-parse', '--short', 'HEAD']); - const commitHash = hashResult.stdout || 'unknown'; + commitHash = hashResult.stdout || 'unknown'; + } - // 4. Update work item + // 4. Update work item (always update, even when --force is used). When + // forced, there is no commit hash — note that in the comment. + try { console.log(`Updating work item ${workItemId}...`); spawnSync('wl', ['update', workItemId, '--stage', 'in_review', '--status', 'completed', '--json'], { stdio: 'pipe', encoding: 'utf8', }); - spawnSync('wl', ['comment', 'add', workItemId, '--comment', `Work completed in dev container ${cName}. Branch: ${pushBranch}. Latest commit: ${commitHash}`, '--author', 'ampa', '--json'], { + const commentMsg = commitHash + ? `Work completed in dev container ${cName}. Branch: ${branch || 'HEAD'}. Latest commit: ${commitHash}` + : `Work completed in dev container ${cName}. Branch: ${branch || 'HEAD'}. No commit pushed (finished with --force or no new commits).`; + spawnSync('wl', ['comment', 'add', workItemId, '--comment', commentMsg, '--author', 'ampa', '--json'], { stdio: 'pipe', encoding: 'utf8', }); + } catch (e) { + // Non-fatal — continue to cleanup even if wl update/comment fail } - // 5. Release pool claim and mark for cleanup + // 5. Release pool claim and attempt to destroy the container. If the + // container cannot remove itself, mark it for host-side cleanup. if (projectRoot) { try { releasePoolContainer(projectRoot, cName); } catch (e) { // Non-fatal — pool state file may not be accessible from inside container } + // Try to remove the container directly (may fail inside some container + // environments). If direct removal fails, fall back to marking it for + // host-side cleanup. try { - markForCleanup(projectRoot, cName); - console.log(`Container "${cName}" marked for cleanup — it will be destroyed automatically on the next host-side pool operation.`); + const rmRes = runSync('distrobox', ['rm', '--force', cName]); + if (rmRes.status === 0) { + console.log(`Container "${cName}" destroyed.`); + } else { + // Couldn't remove from inside — mark for cleanup + markForCleanup(projectRoot, cName); + console.log(`Container "${cName}" marked for cleanup — it will be destroyed automatically on the next host-side pool operation.`); + } } catch (e) { - // Fallback to manual instructions if marker write fails - console.log(`Container "${cName}" marked for cleanup.`); - console.log('Run the following from the host to destroy the container:'); - console.log(` distrobox rm --force ${cName}`); + try { + markForCleanup(projectRoot, cName); + console.log(`Container "${cName}" marked for cleanup — it will be destroyed automatically on the next host-side pool operation.`); + } catch (ee) { + console.log(`Container "${cName}" marked for cleanup.`); + console.log('Run the following from the host to destroy the container:'); + console.log(` distrobox rm --force ${cName}`); + } } } else { console.log(`Container "${cName}" marked for cleanup.`); @@ -1778,21 +1942,30 @@ async function finishWork(force = false, workItemIdArg) { // Extract commit hash from output const hashMatch = (commitResult.stdout || '').match(/AMPA_COMMIT_HASH=(\S+)/); - const commitHash = hashMatch ? hashMatch[1] : 'unknown'; + commitHashHost = hashMatch ? hashMatch[1] : 'unknown'; - // Update work item from the host - console.log(`Updating work item ${workItemId}...`); - spawnSync('wl', ['update', workItemId, '--stage', 'in_review', '--status', 'completed', '--json'], { - stdio: 'pipe', - encoding: 'utf8', - }); - spawnSync('wl', ['comment', 'add', workItemId, '--comment', `Work completed in dev container ${cName}. Branch: ${branch || 'HEAD'}. Latest commit: ${commitHash}`, '--author', 'ampa', '--json'], { - stdio: 'pipe', - encoding: 'utf8', - }); - } else { - console.log('Warning: Skipping commit/push (--force). Uncommitted changes will be lost.'); - } + } + + // Update work item from the host (always update, even when --force). + try { + console.log(`Updating work item ${workItemId}...`); + spawnSync('wl', ['update', workItemId, '--stage', 'in_review', '--status', 'completed', '--json'], { + stdio: 'pipe', + encoding: 'utf8', + }); + const commentMsgHost = commitHashHost || (commitHash || null) + ? `Work completed in dev container ${cName}. Branch: ${branch || 'HEAD'}. Latest commit: ${commitHashHost || commitHash}` + : `Work completed in dev container ${cName}. Branch: ${branch || 'HEAD'}. No commit pushed (finished with --force or no new commits).`; + spawnSync('wl', ['comment', 'add', workItemId, '--comment', commentMsgHost, '--author', 'ampa', '--json'], { + stdio: 'pipe', + encoding: 'utf8', + }); + } catch (e) { + // Non-fatal + } + if (force) { + console.log('Warning: Skipping commit/push (--force). Uncommitted changes will be lost.'); + } // Release pool claim try { @@ -1943,14 +2116,34 @@ export default function register(ctx) { ampa .command('run') - .description('Run a scheduler command immediately by id') - .arguments('') - .action(async (commandId) => { + .description('Run a scheduler command immediately by id, or list available commands') + .arguments('[command-id]') + .option('--json', 'Output in JSON format') + .option('-F, --format ', 'Human display format (concise|normal|full|raw)') + .option('-w, --watch [seconds]', 'Rerun the command every N seconds (default: 5)') + .option('--verbose', 'Show verbose output including debug messages') + .action(async (commandId, opts, cmd) => { + // Use optsWithGlobals() because wl defines a global --json flag that + // captures the option before it reaches the local opts object. + const allOpts = cmd.optsWithGlobals(); let cwd = process.cwd(); try { cwd = findProjectRoot(cwd); } catch (e) { console.error(e.message); process.exitCode = 2; return; } - const cmdSpec = await resolveRunOnceCommand(cwd, commandId); + // Build extra CLI args to pass through to the Python scheduler + const extraArgs = []; + if (allOpts.json) extraArgs.push('--json'); + if (allOpts.format) extraArgs.push('--format', allOpts.format); + if (allOpts.watch !== undefined) { + if (allOpts.watch === true) { + extraArgs.push('--watch'); + } else { + extraArgs.push('--watch', String(allOpts.watch)); + } + } + if (allOpts.verbose) extraArgs.push('--verbose'); + + const cmdSpec = await resolveRunOnceCommand(cwd, commandId || null, extraArgs); if (!cmdSpec) { - console.error('No run-once command resolved.'); + console.error('No run command resolved.'); process.exitCode = 2; return; } @@ -1964,17 +2157,18 @@ export default function register(ctx) { .option('--json', 'Output JSON') .option('--name ', 'Daemon name', 'default') .option('--verbose', 'Print resolved store path', false) - .action(async (opts) => { - const verbose = !!opts.verbose || process.argv.includes('--verbose'); + .action(async (opts, cmd) => { + const allOpts = cmd.optsWithGlobals(); + const verbose = !!allOpts.verbose || process.argv.includes('--verbose'); let cwd = process.cwd(); try { cwd = findProjectRoot(cwd); } catch (e) { console.error(e.message); process.exitCode = 2; return; } - const daemon = resolveDaemonStore(cwd, opts.name); + const daemon = resolveDaemonStore(cwd, allOpts.name); if (!daemon.running) { console.log(DAEMON_NOT_RUNNING_MESSAGE); process.exitCode = 3; return; } - const cmdSpec = await resolveListCommand(cwd, !!opts.json); + const cmdSpec = await resolveListCommand(cwd, !!allOpts.json); if (!cmdSpec) { console.error('No list command resolved.'); process.exitCode = 2; @@ -1996,17 +2190,18 @@ export default function register(ctx) { .option('--json', 'Output JSON') .option('--name ', 'Daemon name', 'default') .option('--verbose', 'Print resolved store path', false) - .action(async (opts) => { - const verbose = !!opts.verbose || process.argv.includes('--verbose'); + .action(async (opts, cmd) => { + const allOpts = cmd.optsWithGlobals(); + const verbose = !!allOpts.verbose || process.argv.includes('--verbose'); let cwd = process.cwd(); try { cwd = findProjectRoot(cwd); } catch (e) { console.error(e.message); process.exitCode = 2; return; } - const daemon = resolveDaemonStore(cwd, opts.name); + const daemon = resolveDaemonStore(cwd, allOpts.name); if (!daemon.running) { console.log(DAEMON_NOT_RUNNING_MESSAGE); process.exitCode = 3; return; } - const cmdSpec = await resolveListCommand(cwd, !!opts.json); + const cmdSpec = await resolveListCommand(cwd, !!allOpts.json); if (!cmdSpec) { console.error('No list command resolved.'); process.exitCode = 2; @@ -2072,10 +2267,11 @@ export default function register(ctx) { .command('list-containers') .description('List dev containers created by start-work') .option('--json', 'Output JSON') - .action(async (opts) => { + .action(async (opts, cmd) => { + const allOpts = cmd.optsWithGlobals(); let cwd = process.cwd(); try { cwd = findProjectRoot(cwd); } catch (e) { console.error(e.message); process.exitCode = 2; return; } - const code = listContainers(cwd, !!opts.json); + const code = listContainers(cwd, !!allOpts.json); process.exitCode = code; }); @@ -2083,10 +2279,11 @@ export default function register(ctx) { .command('lc') .description('Alias for list-containers') .option('--json', 'Output JSON') - .action(async (opts) => { + .action(async (opts, cmd) => { + const allOpts = cmd.optsWithGlobals(); let cwd = process.cwd(); try { cwd = findProjectRoot(cwd); } catch (e) { console.error(e.message); process.exitCode = 2; return; } - const code = listContainers(cwd, !!opts.json); + const code = listContainers(cwd, !!allOpts.json); process.exitCode = code; }); @@ -2225,6 +2422,8 @@ export { getCleanupList, getGitOrigin, getPoolState, + globalAmpaDir, + globalPluginsDir, imageCreatedDate, imageExists, isImageStale, @@ -2234,9 +2433,11 @@ export { poolCleanupPath, poolContainerName, poolStatePath, + projectAmpaDir, releasePoolContainer, replenishPool, replenishPoolBackground, + resolveAmpaPackage, resolveDaemonStore, saveCleanupList, savePoolState, diff --git a/CLI.md b/CLI.md index 6d298a6..8ca2c9a 100644 --- a/CLI.md +++ b/CLI.md @@ -212,6 +212,8 @@ Options: `-c, --children` — Also display descendants in a tree layout (optional). `--prefix ` (optional) +The output always includes `Risk` and `Effort` fields. When a field has no value a placeholder `—` is shown so the field is consistently visible for triage and prioritization. + Examples: ```sh diff --git a/TUI.md b/TUI.md index 88560b2..81fb074 100644 --- a/TUI.md +++ b/TUI.md @@ -7,6 +7,7 @@ This document describes the interactive terminal UI shipped as the `wl tui` (or - The TUI presents a tree view of work items on the left and a details pane on the right. - It can show all items, or be limited to in-progress items via `--in-progress`. - The details pane uses the same human formatter as the CLI so what you see in the TUI matches `wl show --format full`. +- The metadata pane (top-right) shows Status, Stage, Priority, Risk, Effort, Comments, Tags, Assignee, Created, Updated, and GitHub link for the selected item. `Risk` and `Effort` always appear; when a field has no value a placeholder `—` is shown. - Integrated OpenCode AI assistant for intelligent work item management and coding assistance. ## Controls diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 0e055e8..ad2b384 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -124,8 +124,8 @@ export function displayItemTree(items: WorkItem[]): void { ? `Status: ${item.status} · Stage: ${effectiveStage} | Priority: ${item.priority}` : `Status: ${item.status} | Priority: ${item.priority}`; console.log(`${detailIndent}${statusSummary}`); - if (item.risk) console.log(`${detailIndent}Risk: ${item.risk}`); - if (item.effort) console.log(`${detailIndent}Effort: ${item.effort}`); + console.log(`${detailIndent}Risk: ${item.risk || '—'}`); + console.log(`${detailIndent}Effort: ${item.effort || '—'}`); if (item.assignee) console.log(`${detailIndent}Assignee: ${item.assignee}`); if (item.tags.length > 0) console.log(`${detailIndent}Tags: ${item.tags.join(', ')}`); } @@ -257,8 +257,8 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, lines.push(`Status: ${statusLabel} | Priority: ${item.priority}`); } lines.push(sortIndexLabel); - if (item.risk) lines.push(`Risk: ${item.risk}`); - if (item.effort) lines.push(`Effort: ${item.effort}`); + lines.push(`Risk: ${item.risk || '—'}`); + lines.push(`Effort: ${item.effort || '—'}`); if (item.assignee) lines.push(`Assignee: ${item.assignee}`); if (item.tags && item.tags.length > 0) lines.push(`Tags: ${item.tags.join(', ')}`); return lines.join('\n'); @@ -277,8 +277,8 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, lines.push(`Status: ${statusLabel} | Priority: ${item.priority}`); } lines.push(sortIndexLabel); - if (item.risk) lines.push(`Risk: ${item.risk}`); - if (item.effort) lines.push(`Effort: ${item.effort}`); + lines.push(`Risk: ${item.risk || '—'}`); + lines.push(`Effort: ${item.effort || '—'}`); if (item.assignee) lines.push(`Assignee: ${item.assignee}`); if (item.parentId) lines.push(`Parent: ${item.parentId}`); if (item.description) lines.push(`Description: ${item.description}`); @@ -323,7 +323,9 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, ['SortIndex', String(item.sortIndex)] ]; if (item.risk) frontmatter.push(['Risk', item.risk]); + else frontmatter.push(['Risk', '—']); if (item.effort) frontmatter.push(['Effort', item.effort]); + else frontmatter.push(['Effort', '—']); if (item.assignee) frontmatter.push(['Assignee', item.assignee]); if (item.parentId) frontmatter.push(['Parent', item.parentId]); if (item.tags && item.tags.length > 0) frontmatter.push(['Tags', item.tags.join(', ')]); diff --git a/src/tui/chords.ts b/src/tui/chords.ts index a08af6b..ad4c95e 100644 --- a/src/tui/chords.ts +++ b/src/tui/chords.ts @@ -62,6 +62,10 @@ export class ChordHandler { } private scheduleClear(): void { + // Clear any previously scheduled timeout before creating a new one so + // we don't accumulate overlapping timers when scheduleClear is called + // repeatedly (for example when duplicate physical events re-schedule + // the leader timeout). if (this.timer) clearTimeout(this.timer as any); this.timer = setTimeout(() => { if (this.pendingHandler) { @@ -81,10 +85,16 @@ export class ChordHandler { try { console.error(`[chords] feed key=${JSON.stringify(key)} -> normalized='${k}', pending=${JSON.stringify(this.pending)}, timer=${this.timer ? 'set' : 'null'}`); } catch (_) {} } // if there is an in-flight pending short-handler timer, cancel it + // Preserve any previously-set pendingHandler: a duplicate physical + // key event should not drop a deferred handler. We will clear the + // timer but keep the handler so the later scheduleClear() call will + // invoke it unless the logic replaces it explicitly. + let prevPendingHandler: Handler | null = null; if (this.timer) { clearTimeout(this.timer as any); this.timer = null; - this.pendingHandler = null; + prevPendingHandler = this.pendingHandler; + // do not set this.pendingHandler = null here; preserve it } const nextPending = [...this.pending, k]; @@ -106,11 +116,17 @@ export class ChordHandler { // state. This avoids cycles where a duplicate leader clears the // pending state and prevents the intended follow-up key from // matching. - const lp = this.pending[this.pending.length - 1]; const lastIsSameAsNew = nextPending.length > 1 && nextPending[nextPending.length - 1] === nextPending[nextPending.length - 2]; if (lastIsSameAsNew) { if (dbg) try { console.error(`[chords] duplicate key '${k}' ignored (pending=${JSON.stringify(this.pending)})`); } catch (_) {} // Consume the duplicate event but keep pending as-is. + // Restore preserved pendingHandler (if any) and re-schedule + // the leader timeout so the deferred handler still runs after + // the original timeout period even if the timer was cleared + // by the duplicate physical event. + if (prevPendingHandler) this.pendingHandler = prevPendingHandler; + // ensure a timeout is active to eventually invoke pendingHandler + this.scheduleClear(); return true; } diff --git a/src/tui/components/metadata-pane.ts b/src/tui/components/metadata-pane.ts index d87d2f1..c3085f6 100644 --- a/src/tui/components/metadata-pane.ts +++ b/src/tui/components/metadata-pane.ts @@ -64,6 +64,8 @@ export class MetadataPaneComponent { status?: string; stage?: string; priority?: string; + risk?: string; + effort?: string; tags?: string[]; assignee?: string; createdAt?: Date | string; @@ -75,10 +77,13 @@ export class MetadataPaneComponent { this.box.setContent(''); return; } + const placeholder = '—'; const lines: string[] = []; lines.push(`Status: ${item.status ?? ''}`); lines.push(`Stage: ${item.stage ?? ''}`); lines.push(`Priority: ${item.priority ?? ''}`); + lines.push(`Risk: ${item.risk && item.risk.trim() ? item.risk : placeholder}`); + lines.push(`Effort: ${item.effort && item.effort.trim() ? item.effort : placeholder}`); lines.push(`Comments: ${commentCount}`); lines.push(`Tags: ${item.tags && item.tags.length > 0 ? item.tags.join(', ') : ''}`); lines.push(`Assignee: ${item.assignee ?? ''}`); diff --git a/src/tui/controller.ts b/src/tui/controller.ts index 3846159..022be8d 100644 --- a/src/tui/controller.ts +++ b/src/tui/controller.ts @@ -243,8 +243,24 @@ export class TuiController { widget._reading = false; } } catch (_) {} - try { (screen as any).grabKeys = false; } catch (_) {} - try { (screen as any).program?.hideCursor?.(); } catch (_) {} + try { + // Prefer blessed API when available; fall back to property assignment + if (typeof (screen as any).grabKeys === 'function') { + try { (screen as any).grabKeys(false); } catch (_) { (screen as any).grabKeys = false; } + } else { + (screen as any).grabKeys = false; + } + } catch (err) { + // best-effort cleanup; log when verbose to help diagnose issues + try { debugLog(`endUpdateDialogCommentReading: failed to clear grabKeys: ${String(err)}`); } catch (_) {} + } + try { + if (typeof (screen as any).program?.hideCursor === 'function') { + (screen as any).program.hideCursor(); + } + } catch (err) { + try { debugLog(`endUpdateDialogCommentReading: failed to hide cursor: ${String(err)}`); } catch (_) {} + } }; const startUpdateDialogCommentReading = () => { @@ -539,12 +555,23 @@ export class TuiController { // Clear any pending state held by the chord handler (leader+wait) try { chordHandler.reset(); } catch (_) {} }; + const endOpencodeTextReading = () => { + // Best-effort cleanup: widget lifecycle differs across blessed versions + // and test doubles, so failures here should not block user input flow. + try { + const widget = opencodeText as any; + if (typeof widget?.cancel === 'function') widget.cancel(); + } catch (_) {} + try { (screen as any).grabKeys = false; } catch (_) {} + try { (screen as any).program?.hideCursor?.(); } catch (_) {} + }; // Register Ctrl-W chord handlers if (chordDebug) console.error('[tui] registering ctrl-w chord handlers'); chordHandler.register(['C-w', 'w'], () => { if (helpMenu.isVisible()) return; if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return; + endOpencodeTextReading(); clearCtrlWPending(); cycleFocus(1); screen.render(); @@ -553,6 +580,7 @@ export class TuiController { chordHandler.register(['C-w', 'p'], () => { if (helpMenu.isVisible()) return; if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return; + endOpencodeTextReading(); clearCtrlWPending(); focusPaneByIndex(lastPaneFocusIndex); screen.render(); @@ -565,6 +593,7 @@ export class TuiController { chordHandler.register(['C-w', 'h'], () => { if (helpMenu.isVisible()) return; if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return; + endOpencodeTextReading(); clearCtrlWPending(); const current = getActivePaneIndex(); focusPaneByIndex(current - 1); @@ -574,6 +603,7 @@ export class TuiController { chordHandler.register(['C-w', 'l'], () => { if (helpMenu.isVisible()) return; if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return; + endOpencodeTextReading(); clearCtrlWPending(); const current = getActivePaneIndex(); focusPaneByIndex(current + 1); @@ -601,6 +631,7 @@ export class TuiController { if (!detailModal.hidden || !nextDialog.hidden || !closeDialog.hidden || !updateDialog.hidden) return; if (opencodeDialog.hidden) return; if (!opencodePane || (opencodePane as any).hidden) return; + endOpencodeTextReading(); clearCtrlWPending(); (opencodePane as Pane).focus?.(); syncFocusFromScreen(); @@ -1214,6 +1245,7 @@ export class TuiController { function closeOpencodeDialog() { // In compact mode, don't hide the dialog - it stays as the input bar // Just clear the input and keep it open + endOpencodeTextReading(); try { if (typeof opencodeText.clearValue === 'function') opencodeText.clearValue(); } catch (_) {} try { if (typeof opencodeText.setValue === 'function') opencodeText.setValue(''); } catch (_) {} setOpencodeCursorIndex('', 0); @@ -1223,6 +1255,7 @@ export class TuiController { } function closeOpencodePane() { + endOpencodeTextReading(); if (opencodePane) { opencodePane.hide(); } @@ -1485,6 +1518,7 @@ export class TuiController { // Add Escape key handler to close the opencode dialog const opencodeTextEscapeHandler = function(this: any) { + endOpencodeTextReading(); opencodeDialog.hide(); if (opencodePane) { opencodePane.hide(); @@ -1571,6 +1605,7 @@ export class TuiController { // to the main list. Use a named handler so it can be removed during // cleanup in tests that repeatedly create/destroy dialogs. const opencodeDialogEscapeHandler = () => { + endOpencodeTextReading(); opencodeDialog.hide(); if (opencodePane) { opencodePane.hide(); @@ -2030,13 +2065,75 @@ export class TuiController { const dataPath = getDefaultDataPath(); const dataDir = pathImpl.dirname(dataPath); const dataFile = pathImpl.basename(dataPath); + const readDataMtimeMs = () => { + try { + return fsImpl.statSync(dataPath).mtimeMs; + } catch (err) { + debugLog(`Failed to read data file mtime for watch event filtering: ${String(err)}`); + return null; + } + }; + let lastKnownDataMtimeMs = readDataMtimeMs(); try { - dataWatcher = fsImpl.watch(dataDir, (eventType, filename) => { + // Use a lightweight debounce and avoid synchronous fs.statSync in + // the watch handler which can block the event loop under heavy + // filesystem activity. We schedule an async check to compare mtime + // and only trigger a refresh when the file's mtime actually changed. + let watchDebounce: ReturnType | null = null; + // Initialize lastSeenMtimeMs from the current known mtime so we do + // not trigger a refresh for the first watch callback when the file + // has not actually changed since startup. Previously this was + // initialized to null which caused an extra refresh call in tests + // that expect no-op behavior when mtime is unchanged. + let lastSeenMtimeMs: number | null = lastKnownDataMtimeMs; + dataWatcher = fsImpl.watch(dataDir, (_eventType, filename) => { if (isShuttingDown) return; - if (eventType !== 'change' && eventType !== 'rename') return; if (filename && filename !== dataFile) return; - const selectedIndex = typeof list.selected === 'number' ? (list.selected as number) : 0; - scheduleRefreshFromDatabase(selectedIndex); + // debounce rapid successive watch callbacks + if (watchDebounce) clearTimeout(watchDebounce); + watchDebounce = setTimeout(async () => { + watchDebounce = null; + try { + // Prefer using injected statSync when available because tests + // commonly mock it. If statSync exists but throws, treat the + // failure as a transient error and do NOT fall back to the + // async stat path (which may observe a different file) to + // avoid scheduling spurious refreshes. Only attempt the + // async stat when statSync is not present on the injected + // fsImpl. + let stat: fs.Stats | null = null; + const hasSync = typeof (fsImpl as any).statSync === 'function'; + if (hasSync) { + try { + stat = (fsImpl as any).statSync(dataPath); + } catch (e) { + // statSync exists but failed — ignore this watch event + // rather than attempting async stat which can cause + // inconsistent results in tests. + return; + } + } else { + stat = await fsAsync.stat(dataPath).catch(() => null); + } + const mtimeMs = stat?.mtimeMs ?? null; + if (mtimeMs === null) { + // Could not read mtime (stat failed) — ignore this watch + // event rather than triggering a refresh. Transient stat + // failures should not cause spurious refreshes. + return; + } + if (lastSeenMtimeMs === null || mtimeMs !== lastSeenMtimeMs) { + lastSeenMtimeMs = mtimeMs; + const selectedIndex = typeof list.selected === 'number' ? (list.selected as number) : 0; + scheduleRefreshFromDatabase(selectedIndex); + } + } catch (err) { + // best-effort; log when verbose + try { debugLog(`startDatabaseWatch: watch handler error: ${String(err)}`); } catch (_) {} + const selectedIndex = typeof list.selected === 'number' ? (list.selected as number) : 0; + scheduleRefreshFromDatabase(selectedIndex); + } + }, 75); }); } catch (_) { dataWatcher = null; diff --git a/test/tui-chords.test.ts b/test/tui-chords.test.ts index c6b121e..3dbc255 100644 --- a/test/tui-chords.test.ts +++ b/test/tui-chords.test.ts @@ -46,4 +46,25 @@ describe('ChordHandler', () => { expect(p2).toBe(true); expect(abCalled).toBe(true); }); + + it('preserves deferred handler when duplicate physical key events arrive', async () => { + const c = new ChordHandler({ timeoutMs: 30 }); + let leaderHandlerCalled = false; + // Register a leader handler with a follow-up so the leader is deferred + c.register(['C-w'], () => { leaderHandlerCalled = true; }); + c.register(['C-w', 'w'], () => {}); + + // feed Ctrl-W to set pending and deferred handler + const p1 = c.feed({ name: 'w', ctrl: true }); + expect(p1).toBe(true); + expect(leaderHandlerCalled).toBe(false); + + // simulate a duplicate physical delivery of the same leader key (e.g. raw+wrapper) + const pDup = c.feed({ name: 'w', ctrl: true }); + expect(pDup).toBe(true); + + // wait for timeout to elapse and allow deferred handler to run + await new Promise(res => setTimeout(res, 50)); + expect(leaderHandlerCalled).toBe(true); + }); }); diff --git a/test/tui-integration.test.ts b/test/tui-integration.test.ts index 8b54e1d..3877710 100644 --- a/test/tui-integration.test.ts +++ b/test/tui-integration.test.ts @@ -37,8 +37,10 @@ const blessedMock = { clearValue: vi.fn(), focus: vi.fn(() => { widget._screen!.focused = widget; + widget._screen!.grabKeys = true; handlersByEvent['focus']?.(); }), + cancel: vi.fn(), show: vi.fn(() => { widget.hidden = false; }), hide: vi.fn(() => { widget.hidden = true; }), setScrollPerc: vi.fn(), @@ -452,6 +454,155 @@ describe('TUI integration: style preservation', () => { expect(textarea?.focus).toHaveBeenCalled(); }); + it('releases screen grabKeys when leaving opencode textarea via Ctrl+W then k', async () => { + vi.resetModules(); + let savedAction: Function | null = null; + const program: any = { + opts: () => ({ verbose: false }), + command() { return this; }, + description() { return this; }, + option() { return this; }, + action(fn: Function) { savedAction = fn; return this; }, + }; + + const utils = { + requireInitialized: () => {}, + getDatabase: () => ({ + list: () => [{ id: 'WL-TEST-1', title: 'Item', status: 'open' }], + getPrefix: () => 'default', + getCommentsForWorkItem: (_id: string) => [], + get: () => ({ id: 'WL-TEST-1', title: 'Item', status: 'open' }), + }), + }; + + const opencodeClient = { + getStatus: () => ({ status: 'running', port: 9999 }), + startServer: vi.fn().mockResolvedValue(undefined), + stopServer: vi.fn(), + sendPrompt: vi.fn().mockResolvedValue(undefined), + }; + + vi.doMock('../src/tui/opencode-client.js', () => ({ + OpencodeClient: function() { return opencodeClient; }, + })); + + const mod = await import('../src/commands/tui'); + const register = mod.default || mod; + register({ program, utils, blessed: blessedMock } as any); + + await (savedAction as any)({}); + + const textarea = (blessedMock as any)._lastTextarea; + const screen = (blessedMock as any)._lastScreen; + const boxMock = (blessedMock as any).box?.mock; + + const ensureHandler = handlers['screen-key:o'] || handlers['screen-key:O']; + if (ensureHandler) await ensureHandler(null, { name: 'o' }); + const sendHandler = handlers['key']; + if (sendHandler) sendHandler.call(textarea, null, { name: 'enter' }); + + const updatedBoxCalls = boxMock?.calls || []; + const responsePaneIndex = updatedBoxCalls.findIndex((call: any[]) => call?.[0]?.label === ' opencode [esc] '); + const responsePane = responsePaneIndex >= 0 ? boxMock.results[responsePaneIndex]?.value : null; + expect(responsePane).toBeTruthy(); + responsePane?.show?.(); + + textarea?.focus?.(); + expect(screen.grabKeys).toBe(true); + + const screenKeyCtrlW = handlers['screen-key:C-w']; + const screenKeyK = handlers['screen-key:k']; + expect(typeof screenKeyCtrlW).toBe('function'); + expect(typeof screenKeyK).toBe('function'); + + screenKeyCtrlW(null, { name: 'C-w' }); + screenKeyK(null, { name: 'k' }); + + expect(responsePane?.focus).toHaveBeenCalled(); + expect(screen.grabKeys).toBe(false); + }); + + it('skips watch events with unchanged data mtime', async () => { + vi.resetModules(); + vi.useFakeTimers(); + let savedAction: Function | null = null; + const watchCallbacks: Array<(eventType: string, filename?: string) => void> = []; + let dataMtimeMs = 1000; + let mtimeReadError = false; + const program: any = { + opts: () => ({ verbose: false }), + command() { return this; }, + description() { return this; }, + option() { return this; }, + action(fn: Function) { savedAction = fn; return this; }, + }; + + const listMock = vi.fn(() => [{ id: 'WL-TEST-1', title: 'Item', status: 'open' }]); + const utils = { + requireInitialized: () => {}, + getDatabase: () => ({ + list: listMock, + getPrefix: () => 'default', + getCommentsForWorkItem: (_id: string) => [], + get: () => ({ id: 'WL-TEST-1', title: 'Item', status: 'open' }), + }), + }; + + vi.doMock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + watch: vi.fn((_dir: string, cb: (eventType: string, filename?: string) => void) => { + watchCallbacks.push(cb); + return { close: vi.fn() } as any; + }), + statSync: vi.fn((targetPath: any, options?: any) => { + if (String(targetPath).endsWith('.jsonl')) { + if (mtimeReadError) throw new Error('stat failed'); + return { mtimeMs: dataMtimeMs } as any; + } + return (actual.statSync as any)(targetPath, options); + }), + }; + }); + + const opencodeClient = { + getStatus: () => ({ status: 'running', port: 9999 }), + startServer: vi.fn().mockResolvedValue(undefined), + stopServer: vi.fn(), + sendPrompt: vi.fn().mockResolvedValue(undefined), + }; + vi.doMock('../src/tui/opencode-client.js', () => ({ + OpencodeClient: function() { return opencodeClient; }, + })); + + const mod = await import('../src/commands/tui'); + const register = mod.default || mod; + register({ program, utils, blessed: blessedMock } as any); + await (savedAction as any)({}); + + expect(watchCallbacks.length).toBeGreaterThan(0); + const onWatch = watchCallbacks[0]; + const baselineCalls = listMock.mock.calls.length; + + onWatch('change', undefined); + await vi.advanceTimersByTimeAsync(400); + expect(listMock.mock.calls.length).toBe(baselineCalls); + + dataMtimeMs = 2000; + onWatch('change', undefined); + await vi.advanceTimersByTimeAsync(400); + expect(listMock.mock.calls.length).toBeGreaterThan(baselineCalls); + + const afterChangedMtimeCalls = listMock.mock.calls.length; + mtimeReadError = true; + onWatch('change', undefined); + await vi.advanceTimersByTimeAsync(400); + expect(listMock.mock.calls.length).toBe(afterChangedMtimeCalls); + + vi.useRealTimers(); + }); + it('updates border styles when focus changes', async () => { vi.resetModules(); let savedAction: Function | null = null; diff --git a/tests/cli/helpers-tree-rendering.test.ts b/tests/cli/helpers-tree-rendering.test.ts index 84e7ead..04b75b3 100644 --- a/tests/cli/helpers-tree-rendering.test.ts +++ b/tests/cli/helpers-tree-rendering.test.ts @@ -105,4 +105,54 @@ describe('tree rendering helpers', () => { expect(childBIndex).toBeGreaterThanOrEqual(0); expect(childBIndex).toBeLessThan(childAIndex); }); + + it('shows Risk and Effort placeholders when fields are empty', () => { + const item = baseWorkItem({ id: 'TEST-RISK-1', title: 'Risk Test', risk: '', effort: '' }); + + const { lines, spy } = captureConsole(); + displayItemTree([item]); + spy.mockRestore(); + + const normalized = lines.map(stripAnsi); + expect(normalized.some(line => line.includes('Risk: —'))).toBe(true); + expect(normalized.some(line => line.includes('Effort: —'))).toBe(true); + }); + + it('shows Risk and Effort values when set', () => { + const item = baseWorkItem({ id: 'TEST-RISK-2', title: 'Risk Set', risk: 'High', effort: 'M' }); + + const { lines, spy } = captureConsole(); + displayItemTree([item]); + spy.mockRestore(); + + const normalized = lines.map(stripAnsi); + expect(normalized.some(line => line.includes('Risk: High'))).toBe(true); + expect(normalized.some(line => line.includes('Effort: M'))).toBe(true); + }); + + it('shows Risk and Effort in concise format output', () => { + const item = baseWorkItem({ id: 'TEST-RISK-3', title: 'Concise Test', risk: 'Low', effort: 'XS' }); + const items = [item]; + + const { lines, spy } = captureConsole(); + displayItemTreeWithFormat(items, null, 'concise'); + spy.mockRestore(); + + const normalized = lines.map(stripAnsi).join('\n'); + expect(normalized).toContain('Risk: Low'); + expect(normalized).toContain('Effort: XS'); + }); + + it('shows Risk and Effort placeholders in normal format when fields are empty', () => { + const item = baseWorkItem({ id: 'TEST-RISK-4', title: 'Normal Test', risk: '', effort: '' }); + const items = [item]; + + const { lines, spy } = captureConsole(); + displayItemTreeWithFormat(items, null, 'normal'); + spy.mockRestore(); + + const normalized = lines.map(stripAnsi).join('\n'); + expect(normalized).toContain('Risk: —'); + expect(normalized).toContain('Effort: —'); + }); }); diff --git a/tests/tui/tui-50-50-layout.test.ts b/tests/tui/tui-50-50-layout.test.ts index 1c23d08..ee01d3b 100644 --- a/tests/tui/tui-50-50-layout.test.ts +++ b/tests/tui/tui-50-50-layout.test.ts @@ -450,6 +450,8 @@ describe('TUI 50/50 split layout', () => { expect(capturedContent).toContain('in-progress'); expect(capturedContent).toContain('Priority:'); expect(capturedContent).toContain('high'); + expect(capturedContent).toContain('Risk:'); + expect(capturedContent).toContain('Effort:'); expect(capturedContent).toContain('Comments: 3'); expect(capturedContent).toContain('Tags:'); expect(capturedContent).toContain('backend'); @@ -460,11 +462,11 @@ describe('TUI 50/50 split layout', () => { expect(capturedContent).toContain('Jan 1, 2024'); expect(capturedContent).toContain('Updated:'); expect(capturedContent).toContain('Jun 1, 2024'); - // GitHub row is always present (9th row) + // GitHub row is always present (11th row) expect(capturedContent).toContain('GitHub:'); // All rows should always be present (consistent layout) const lines = capturedContent.split('\n'); - expect(lines.length).toBe(9); + expect(lines.length).toBe(11); }); it('MetadataPaneComponent.updateFromItem clears content for null item', () => { @@ -518,9 +520,9 @@ describe('TUI 50/50 split layout', () => { assignee: '', }, 0); - // All 9 rows should always be present for consistent layout + // All 11 rows should always be present for consistent layout const lines = capturedContent.split('\n'); - expect(lines.length).toBe(9); + expect(lines.length).toBe(11); expect(capturedContent).toContain('Status:'); expect(capturedContent).toContain('Tags:'); expect(capturedContent).toContain('Assignee:'); diff --git a/tests/tui/tui-github-metadata.test.ts b/tests/tui/tui-github-metadata.test.ts index e2d5dce..bd517ae 100644 --- a/tests/tui/tui-github-metadata.test.ts +++ b/tests/tui/tui-github-metadata.test.ts @@ -103,20 +103,20 @@ describe('MetadataPaneComponent GitHub row', () => { expect(content).toContain('G to push'); }); - it('always renders exactly 9 rows regardless of GitHub state', () => { + it('always renders exactly 11 rows regardless of GitHub state', () => { const { comp, getContent } = createMockMetadataPane(); // With no github fields comp.updateFromItem({ status: 'open' }, 0); - expect(getContent().split('\n').length).toBe(9); + expect(getContent().split('\n').length).toBe(11); // With github mapping comp.updateFromItem({ status: 'open', githubRepo: 'o/r', githubIssueNumber: 1 }, 0); - expect(getContent().split('\n').length).toBe(9); + expect(getContent().split('\n').length).toBe(11); // With github configured but no mapping comp.updateFromItem({ status: 'open', githubRepo: 'o/r' }, 0); - expect(getContent().split('\n').length).toBe(9); + expect(getContent().split('\n').length).toBe(11); }); it('clears content for null item', () => { @@ -127,8 +127,35 @@ describe('MetadataPaneComponent GitHub row', () => { }); // --------------------------------------------------------------------------- -// Integration tests: controller passes github fields to updateFromItem +// Unit tests: MetadataPaneComponent Risk and Effort row rendering // --------------------------------------------------------------------------- +describe('MetadataPaneComponent Risk and Effort rows', () => { + it('shows placeholder when risk and effort are not set', () => { + const { comp, getContent } = createMockMetadataPane(); + comp.updateFromItem({ status: 'open' }, 0); + expect(getContent()).toContain('Risk:'); + expect(getContent()).toContain('Effort:'); + expect(getContent()).toMatch(/Risk:\s+—/); + expect(getContent()).toMatch(/Effort:\s+—/); + }); + + it('shows risk and effort values when set', () => { + const { comp, getContent } = createMockMetadataPane(); + comp.updateFromItem({ status: 'open', risk: 'High', effort: 'M' }, 0); + const content = getContent(); + expect(content).toMatch(/Risk:\s+High/); + expect(content).toMatch(/Effort:\s+M/); + }); + + it('shows placeholder when risk and effort are empty strings', () => { + const { comp, getContent } = createMockMetadataPane(); + comp.updateFromItem({ status: 'open', risk: '', effort: '' }, 0); + const content = getContent(); + expect(content).toMatch(/Risk:\s+—/); + expect(content).toMatch(/Effort:\s+—/); + }); +}); + describe('TUI metadata pane receives GitHub fields', () => { it('calls updateFromItem after start', async () => { const ctx = createTuiTestContext();