feat(agent-queue): interactive dashboard — navigable job list + single-key actions
Turn dash into a menu-driven control panel (single mjs script): - numbered, arrow/j-k/1-9 selectable JOBS list (review/testing/failed/inbox) - single-key actions wired to agent-queue.sh (single source of truth): p promote, s ship, x reject, u requeue (reject/requeue confirm y/n) - enter/l opens a live log viewer; r starts a detached run loop, S stops it - run-loop pid indicator, transient action flashes, ? help overlay - non-TTY falls back to the read-only live view - README: dash command + interactive key table
This commit is contained in:
parent
4ed4d75a67
commit
dde677f4b9
@ -112,7 +112,7 @@ misparsed as a flag.
|
|||||||
| `run [--max N] [--engine E] [--once]` | process the inbox (foreground loop) |
|
| `run [--max N] [--engine E] [--once]` | process the inbox (foreground loop) |
|
||||||
| `status` | kanban counts + running-worker table (marks `⚠ stalled` workers) |
|
| `status` | kanban counts + running-worker table (marks `⚠ stalled` workers) |
|
||||||
| `watch [interval]` | live `status` (bash), redrawn every N seconds (default 2) |
|
| `watch [interval]` | live `status` (bash), redrawn every N seconds (default 2) |
|
||||||
| `dash [--interval N]` | richer **Node** live dashboard — running workers (engine, elapsed, last log line, stall) + recent shipped/failed |
|
| `dash [--interval N]` | **interactive Node dashboard** — navigable numbered job list with single-key actions (see below) |
|
||||||
| `stop` | kill running workers + the run loop |
|
| `stop` | kill running workers + the run loop |
|
||||||
| `logs <job> [-f]` | print / follow a job's log |
|
| `logs <job> [-f]` | print / follow a job's log |
|
||||||
| `promote <job>` | advance one stage forward: `review → testing → shipped` |
|
| `promote <job>` | advance one stage forward: `review → testing → shipped` |
|
||||||
@ -124,6 +124,29 @@ misparsed as a flag.
|
|||||||
Only one `run` loop may be active per queue — a second `run` against the same
|
Only one `run` loop may be active per queue — a second `run` against the same
|
||||||
queue is refused while the first is alive (a stale `daemon.pid` is cleared).
|
queue is refused while the first is alive (a stale `daemon.pid` is cleared).
|
||||||
|
|
||||||
|
### Interactive dashboard (`dash`)
|
||||||
|
|
||||||
|
`dash` is a single-script, menu-driven control panel (think a tiny "glassbox").
|
||||||
|
It shows the kanban counts, live RUNNING workers (engine, elapsed, last log
|
||||||
|
line, stall), a **navigable numbered JOBS list**, and RECENT finished jobs — and
|
||||||
|
lets you act on jobs without leaving the screen. Every action shells out to
|
||||||
|
`agent-queue.sh`, so the script stays the single source of truth.
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | ------ |
|
||||||
|
| `↑`/`↓`, `j`/`k`, `1`–`9` | select a job in the JOBS list |
|
||||||
|
| `enter` / `l` | view the selected job's log (live, auto-refreshing) |
|
||||||
|
| `p` | promote (`review → testing → shipped`) |
|
||||||
|
| `s` | ship (`testing`/QA → `shipped`, the manual gate) |
|
||||||
|
| `x` | reject (`review`/`testing` → `failed`) — asks `y/n` |
|
||||||
|
| `u` | requeue (`failed`/`review`/`testing` → `inbox`) — asks `y/n` |
|
||||||
|
| `r` | start the `run` loop (detached → `logs/run-loop.log`) |
|
||||||
|
| `S` | stop the run loop + running workers |
|
||||||
|
| `g` | refresh now · `?`/`h` help · `q`/`Ctrl-C` quit |
|
||||||
|
|
||||||
|
The header shows a `● run loop pid N` / `○ run loop stopped` indicator. Run it in
|
||||||
|
a TTY for the interactive mode; piped/non-TTY it falls back to a read-only live view.
|
||||||
|
|
||||||
## Via `bytelyst-cli.sh`
|
## Via `bytelyst-cli.sh`
|
||||||
|
|
||||||
Wired into the repo's unified CLI (no GitHub token required for this subcommand):
|
Wired into the repo's unified CLI (no GitHub token required for this subcommand):
|
||||||
|
|||||||
@ -1,19 +1,26 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// agent-queue dashboard — a zero-dependency live TUI for the folder queue.
|
// agent-queue dashboard — a zero-dependency, INTERACTIVE TUI for the folder queue.
|
||||||
//
|
//
|
||||||
// Reads the same queue/ state written by agent-queue.sh and re-renders a board
|
// Reads the same queue/ state written by agent-queue.sh and re-renders a board
|
||||||
// every interval: kanban counts, running workers (engine, elapsed, last log line),
|
// every interval: kanban counts, running workers (engine, elapsed, last log line),
|
||||||
// and the most recent shipped/failed jobs.
|
// and a navigable, numbered job list you can act on without leaving the screen.
|
||||||
//
|
//
|
||||||
// Lifecycle: inbox → building → review → testing → shipped (+ failed)
|
// Lifecycle: inbox → building → review → testing → shipped (+ failed)
|
||||||
//
|
//
|
||||||
|
// Interactive keys (when run in a TTY):
|
||||||
|
// ↑/↓ or j/k or 1-9 select a job enter / l view its log
|
||||||
|
// p promote s ship (testing→shipped) x reject
|
||||||
|
// u requeue r run loop S stop g refresh now
|
||||||
|
// ? help q / Ctrl-C quit
|
||||||
|
// All actions shell out to agent-queue.sh — it stays the single source of truth.
|
||||||
|
//
|
||||||
// Usage: node dashboard.mjs [--interval 2] [--root /path/to/queue]
|
// Usage: node dashboard.mjs [--interval 2] [--root /path/to/queue]
|
||||||
// AGENT_QUEUE_ROOT=/path node dashboard.mjs
|
// AGENT_QUEUE_ROOT=/path node dashboard.mjs
|
||||||
// Quit: q or Ctrl-C
|
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@ -105,10 +112,116 @@ const readMetas = () => {
|
|||||||
return files.map((f) => parseMeta(path.join(DIRS.state, f)));
|
return files.map((f) => parseMeta(path.join(DIRS.state, f)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── agent-queue.sh control (single source of truth) ─────────────────
|
||||||
|
const AQ = path.join(__dirname, 'agent-queue.sh');
|
||||||
|
const stripAnsi = (s) => (s || '').replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
|
const lastLine = (s) => {
|
||||||
|
const lines = stripAnsi(s).split('\n').map((l) => l.trim()).filter(Boolean);
|
||||||
|
return lines.length ? lines[lines.length - 1] : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// aq(args) — run an agent-queue.sh subcommand, capturing output (never throws).
|
||||||
|
const aq = (args) => {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('bash', [AQ, ...args], {
|
||||||
|
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, AGENT_QUEUE_ROOT: ROOT },
|
||||||
|
});
|
||||||
|
return { ok: true, out };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, out: ((e.stdout || '') + (e.stderr || '') || e.message || '').toString() };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// daemonPid() — pid of a live `run` loop, or null.
|
||||||
|
const daemonPid = () => {
|
||||||
|
try {
|
||||||
|
const pid = fs.readFileSync(path.join(DIRS.state, 'daemon.pid'), 'utf8').trim();
|
||||||
|
return pid && pidAlive(pid) ? pid : null;
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// startRun() — spawn a detached `run` loop writing to logs/run-loop.log.
|
||||||
|
const startRun = () => {
|
||||||
|
if (daemonPid()) { setFlash(c('yellow', 'run loop already active')); return; }
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(path.join(DIRS.logs, 'run-loop.log'), 'a');
|
||||||
|
const child = spawn('bash', [AQ, 'run'], {
|
||||||
|
detached: true, stdio: ['ignore', fd, fd],
|
||||||
|
env: { ...process.env, AGENT_QUEUE_ROOT: ROOT },
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
setFlash(c('green', `▶ run loop started (max ${process.env.AGENT_QUEUE_MAX || 3})`));
|
||||||
|
} catch (e) { setFlash(c('red', `run failed: ${e.message}`)); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── interactive state ───────────────────────────────────────────────
|
||||||
|
const INTERACTIVE = !!process.stdin.isTTY;
|
||||||
|
const ACTION_STAGES = ['review', 'testing', 'failed', 'inbox'];
|
||||||
|
let mode = 'board'; // 'board' | 'log' | 'help' | 'confirm'
|
||||||
|
let items = []; // actionable jobs, rebuilt each draw
|
||||||
|
let selIdx = 0; // selected index into items
|
||||||
|
let selJob = null; // selected job name (stable across refreshes)
|
||||||
|
let flash = ''; // transient status message
|
||||||
|
let flashUntil = 0;
|
||||||
|
let logJob = null; // job whose log is being viewed
|
||||||
|
let confirmAction = null; // { verb, job, run }
|
||||||
|
|
||||||
|
const setFlash = (msg, ms = 4000) => { flash = msg; flashUntil = Date.now() + ms; };
|
||||||
|
const flashLine = () => (flash && Date.now() < flashUntil ? flash : '');
|
||||||
|
|
||||||
|
const buildItems = () => {
|
||||||
|
const list = [];
|
||||||
|
for (const st of ACTION_STAGES) {
|
||||||
|
for (const f of listMd(DIRS[st]).sort()) list.push({ stage: st, job: f.replace(/\.md$/, '') });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSelection = () => {
|
||||||
|
if (selJob) {
|
||||||
|
const i = items.findIndex((it) => it.job === selJob);
|
||||||
|
if (i >= 0) { selIdx = i; return; }
|
||||||
|
}
|
||||||
|
selIdx = Math.max(0, Math.min(selIdx, items.length - 1));
|
||||||
|
selJob = items[selIdx]?.job ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAGE_TAG = {
|
||||||
|
review: () => c('cyan', '[review ]'),
|
||||||
|
testing: () => c('cyan', '[testing]'),
|
||||||
|
failed: () => c('red', '[failed ]'),
|
||||||
|
inbox: () => c('blue', '[inbox ]'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// gate(verb, stage) — is this action valid for a job in this stage?
|
||||||
|
const gate = (verb, stage) => ({
|
||||||
|
promote: stage === 'review' || stage === 'testing',
|
||||||
|
ship: stage === 'testing',
|
||||||
|
reject: stage === 'review' || stage === 'testing',
|
||||||
|
requeue: stage === 'failed' || stage === 'review' || stage === 'testing',
|
||||||
|
logs: true,
|
||||||
|
}[verb]);
|
||||||
|
|
||||||
|
// doAction(verb) — run the gated action on the selected job via agent-queue.sh.
|
||||||
|
const doAction = (verb) => {
|
||||||
|
const it = items[selIdx];
|
||||||
|
if (!it) { setFlash(c('gray', 'no job selected')); return; }
|
||||||
|
if (!gate(verb, it.stage)) { setFlash(c('gray', `${verb} not valid for a ${it.stage} job`)); return; }
|
||||||
|
if ((verb === 'reject' || verb === 'requeue') && mode !== 'confirm') {
|
||||||
|
confirmAction = { verb, job: it.job, run: () => doAction(verb) };
|
||||||
|
mode = 'confirm';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = aq([verb, it.job]);
|
||||||
|
setFlash((r.ok ? c('green', '✓ ') : c('red', '✗ ')) + (lastLine(r.out) || `${verb} ${it.job}`));
|
||||||
|
mode = 'board'; confirmAction = null;
|
||||||
|
};
|
||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────
|
||||||
const ENGINE_COLOR = { devin: 'cyan', claude: 'yellow', codex: 'green' };
|
const ENGINE_COLOR = { devin: 'cyan', claude: 'yellow', codex: 'green' };
|
||||||
|
|
||||||
function render() {
|
function drawBoard() {
|
||||||
const metas = readMetas();
|
const metas = readMetas();
|
||||||
const running = metas.filter((m) => !m.ended && pidAlive(m.pid));
|
const running = metas.filter((m) => !m.ended && pidAlive(m.pid));
|
||||||
const finished = metas
|
const finished = metas
|
||||||
@ -121,10 +234,19 @@ function render() {
|
|||||||
shipped: count(DIRS.shipped), failed: count(DIRS.failed),
|
shipped: count(DIRS.shipped), failed: count(DIRS.failed),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// rebuild actionable list + keep selection stable
|
||||||
|
items = buildItems();
|
||||||
|
syncSelection();
|
||||||
|
|
||||||
|
const loop = daemonPid();
|
||||||
const out = [];
|
const out = [];
|
||||||
out.push('');
|
out.push('');
|
||||||
out.push(` ${C.bold}AGENT QUEUE${C.reset} ${c('gray', ROOT)}`);
|
out.push(` ${C.bold}AGENT QUEUE${C.reset} ${c('gray', ROOT)}`);
|
||||||
out.push(` ${c('gray', new Date().toLocaleTimeString())} refresh ${INTERVAL / 1000}s ${c('gray', 'press q to quit')}`);
|
out.push(
|
||||||
|
` ${c('gray', new Date().toLocaleTimeString())} refresh ${INTERVAL / 1000}s ` +
|
||||||
|
(loop ? c('green', `● run loop pid ${loop}`) : c('gray', '○ run loop stopped')) +
|
||||||
|
` ${c('gray', INTERACTIVE ? 'press ? for help' : 'read-only')}`
|
||||||
|
);
|
||||||
out.push('');
|
out.push('');
|
||||||
out.push(
|
out.push(
|
||||||
` ${c('blue', '▢ inbox')} ${String(counts.inbox).padEnd(3)}` +
|
` ${c('blue', '▢ inbox')} ${String(counts.inbox).padEnd(3)}` +
|
||||||
@ -147,13 +269,13 @@ function render() {
|
|||||||
const engC = ENGINE_COLOR[eng] || 'gray';
|
const engC = ENGINE_COLOR[eng] || 'gray';
|
||||||
const age = logAgeSec(m.job);
|
const age = logAgeSec(m.job);
|
||||||
const stalled = age !== null && age > STALL_MIN * 60;
|
const stalled = age !== null && age > STALL_MIN * 60;
|
||||||
const line =
|
out.push(
|
||||||
` ${c('bold', trunc(m.job || '?', 30).padEnd(30))} ` +
|
` ${c('bold', trunc(m.job || '?', 30).padEnd(30))} ` +
|
||||||
`${c(engC, eng.padEnd(7))} ` +
|
`${c(engC, eng.padEnd(7))} ` +
|
||||||
`${fmtElapsed(m.started).padStart(7)} ` +
|
`${fmtElapsed(m.started).padStart(7)} ` +
|
||||||
`${c('gray', 'pid ' + (m.pid || '?'))}` +
|
`${c('gray', 'pid ' + (m.pid || '?'))}` +
|
||||||
`${stalled ? ' ' + c('red', '⚠ stalled') : ''}`;
|
`${stalled ? ' ' + c('red', '⚠ stalled') : ''}`
|
||||||
out.push(line);
|
);
|
||||||
out.push(` ${c('dim', trunc(shortPath(m.cwd || ''), 70))}`);
|
out.push(` ${c('dim', trunc(shortPath(m.cwd || ''), 70))}`);
|
||||||
const last = lastLogLine(m.job);
|
const last = lastLogLine(m.job);
|
||||||
if (last) out.push(` ${c('cyan', '› ')}${c('dim', trunc(last, 70))}`);
|
if (last) out.push(` ${c('cyan', '› ')}${c('dim', trunc(last, 70))}`);
|
||||||
@ -161,17 +283,32 @@ function render() {
|
|||||||
}
|
}
|
||||||
out.push('');
|
out.push('');
|
||||||
|
|
||||||
|
// actionable job list (numbered + selectable)
|
||||||
|
out.push(` ${C.bold}JOBS${C.reset} ${c('gray', '(review · testing · failed · inbox)')}`);
|
||||||
|
if (items.length === 0) {
|
||||||
|
out.push(` ${c('dim', 'no actionable jobs')}`);
|
||||||
|
} else {
|
||||||
|
items.forEach((it, i) => {
|
||||||
|
const sel = i === selIdx;
|
||||||
|
const ptr = sel ? c('cyan', '▶') : ' ';
|
||||||
|
const num = c('gray', String(i + 1).padStart(2) + '.');
|
||||||
|
const tag = (STAGE_TAG[it.stage] || (() => `[${it.stage}]`))();
|
||||||
|
const name = sel ? `${C.bold}${trunc(it.job, 46)}${C.reset}` : trunc(it.job, 46);
|
||||||
|
out.push(` ${ptr} ${num} ${tag} ${name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.push('');
|
||||||
|
|
||||||
// recent finished
|
// recent finished
|
||||||
out.push(` ${C.bold}RECENT${C.reset}`);
|
out.push(` ${C.bold}RECENT${C.reset}`);
|
||||||
const recent = finished.slice(0, 6);
|
const recent = finished.slice(0, 5);
|
||||||
if (recent.length === 0) {
|
if (recent.length === 0) {
|
||||||
out.push(` ${c('dim', 'nothing finished yet')}`);
|
out.push(` ${c('dim', 'nothing finished yet')}`);
|
||||||
} else {
|
} else {
|
||||||
for (const m of recent) {
|
for (const m of recent) {
|
||||||
const res = m.result || '';
|
const res = m.result || '';
|
||||||
const failedRes = res === 'failed' || res === 'timeout' || res === 'verify_failed' || res === 'rejected';
|
const failedRes = res === 'failed' || res === 'timeout' || res === 'verify_failed' || res === 'rejected';
|
||||||
const ok = !failedRes;
|
const mark = failedRes ? c('red', '✕') : c('green', '▣');
|
||||||
const mark = ok ? c('green', '▣') : c('red', '✕');
|
|
||||||
const when = m.ended ? new Date(Number(m.ended) * 1000).toLocaleTimeString() : '';
|
const when = m.ended ? new Date(Number(m.ended) * 1000).toLocaleTimeString() : '';
|
||||||
let label;
|
let label;
|
||||||
if (res === 'shipped') label = c('green', 'shipped');
|
if (res === 'shipped') label = c('green', 'shipped');
|
||||||
@ -191,18 +328,68 @@ function render() {
|
|||||||
}
|
}
|
||||||
out.push('');
|
out.push('');
|
||||||
|
|
||||||
// clear + paint
|
// flash + footer
|
||||||
|
const fl = flashLine();
|
||||||
|
if (fl) out.push(` ${fl}`);
|
||||||
|
if (mode === 'confirm' && confirmAction) {
|
||||||
|
out.push(` ${c('yellow', `${confirmAction.verb} "${confirmAction.job}" ? `)}${C.bold}y${C.reset}${c('gray', '/')}${C.bold}n${C.reset}`);
|
||||||
|
} else if (INTERACTIVE) {
|
||||||
|
out.push(c('gray', ' ↑/↓ select · enter logs · p promote · s ship · x reject · u requeue'));
|
||||||
|
out.push(c('gray', ' r run · S stop · g refresh · ? help · q quit'));
|
||||||
|
}
|
||||||
|
|
||||||
process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n');
|
process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawLog() {
|
||||||
|
const rows = (process.stdout.rows || 30) - 6;
|
||||||
|
let body = `no log for ${logJob}`;
|
||||||
|
try {
|
||||||
|
const txt = fs.readFileSync(path.join(DIRS.logs, `${logJob}.log`), 'utf8');
|
||||||
|
body = txt.split('\n').slice(-rows).join('\n');
|
||||||
|
} catch { /* keep default */ }
|
||||||
|
const head = ` ${C.bold}LOG${C.reset} ${c('cyan', logJob)} ${c('gray', 'q/esc back · g refresh')}`;
|
||||||
|
process.stdout.write('\x1b[2J\x1b[H' + head + '\n' + c('gray', ' ' + '─'.repeat(60)) + '\n' + body + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHelp() {
|
||||||
|
const L = [
|
||||||
|
'', ` ${C.bold}AGENT QUEUE — keys${C.reset}`, '',
|
||||||
|
` ${c('cyan', '↑/↓, j/k, 1-9')} select a job in the JOBS list`,
|
||||||
|
` ${c('cyan', 'enter / l')} view the selected job's log (live)`,
|
||||||
|
` ${c('cyan', 'p')} promote (review → testing → shipped)`,
|
||||||
|
` ${c('cyan', 's')} ship (testing/QA → shipped, the manual gate)`,
|
||||||
|
` ${c('cyan', 'x')} reject (review/testing → failed) ${c('gray', '[confirm]')}`,
|
||||||
|
` ${c('cyan', 'u')} requeue (failed/review/testing → inbox) ${c('gray', '[confirm]')}`,
|
||||||
|
'',
|
||||||
|
` ${c('cyan', 'r')} start the run loop (detached, max ${process.env.AGENT_QUEUE_MAX || 3})`,
|
||||||
|
` ${c('cyan', 'S')} stop the run loop + running workers`,
|
||||||
|
` ${c('cyan', 'g')} refresh now`,
|
||||||
|
` ${c('cyan', '? / h')} toggle this help`,
|
||||||
|
` ${c('cyan', 'q / Ctrl-C')} quit`,
|
||||||
|
'',
|
||||||
|
` ${c('gray', 'Lifecycle: inbox → building → review → testing → shipped (+ failed)')}`,
|
||||||
|
` ${c('gray', 'auto: rc=0 → review; verify pass → testing; verify fail → failed')}`,
|
||||||
|
` ${c('gray', 'manual: ship (testing → shipped)')}`,
|
||||||
|
'', ` ${c('gray', 'press any key to return')}`, '',
|
||||||
|
];
|
||||||
|
process.stdout.write('\x1b[2J\x1b[H' + L.join('\n') + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
if (mode === 'log') drawLog();
|
||||||
|
else if (mode === 'help') drawHelp();
|
||||||
|
else drawBoard();
|
||||||
|
};
|
||||||
|
|
||||||
// ── main loop + key handling ────────────────────────────────────────
|
// ── main loop + key handling ────────────────────────────────────────
|
||||||
if (!fs.existsSync(ROOT)) {
|
if (!fs.existsSync(ROOT)) {
|
||||||
process.stdout.write(`agent-queue: queue root not found: ${ROOT}\nRun \`agent-queue.sh init\` first.\n`);
|
process.stdout.write(`agent-queue: queue root not found: ${ROOT}\nRun \`agent-queue.sh init\` first.\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
render();
|
draw();
|
||||||
const timer = setInterval(render, INTERVAL);
|
const timer = setInterval(draw, INTERVAL);
|
||||||
|
|
||||||
const quit = () => {
|
const quit = () => {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@ -211,13 +398,58 @@ const quit = () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (process.stdin.isTTY) {
|
const moveSel = (delta) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
selIdx = (selIdx + delta + items.length) % items.length;
|
||||||
|
selJob = items[selIdx]?.job ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function onKey(key) {
|
||||||
|
// global quit
|
||||||
|
if (key === '\u0003') return quit(); // Ctrl-C always quits
|
||||||
|
|
||||||
|
if (mode === 'help') { mode = 'board'; return draw(); }
|
||||||
|
if (mode === 'log') {
|
||||||
|
if (key === 'q' || key === '\u001b' || key === '\r' || key === '\n') { mode = 'board'; logJob = null; }
|
||||||
|
else if (key === 'g') { /* fallthrough to redraw */ }
|
||||||
|
return draw();
|
||||||
|
}
|
||||||
|
if (mode === 'confirm') {
|
||||||
|
if (key === 'y' || key === 'Y') confirmAction?.run();
|
||||||
|
else { mode = 'board'; confirmAction = null; setFlash(c('gray', 'cancelled')); }
|
||||||
|
return draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// board mode
|
||||||
|
switch (key) {
|
||||||
|
case 'q': return quit();
|
||||||
|
case '?': case 'h': mode = 'help'; break;
|
||||||
|
case 'g': break; // just redraw
|
||||||
|
case 'j': case '\u001b[B': moveSel(1); break;
|
||||||
|
case 'k': case '\u001b[A': moveSel(-1); break;
|
||||||
|
case '\r': case '\n': case 'l':
|
||||||
|
if (items[selIdx]) { logJob = items[selIdx].job; mode = 'log'; }
|
||||||
|
break;
|
||||||
|
case 'p': doAction('promote'); break;
|
||||||
|
case 's': doAction('ship'); break;
|
||||||
|
case 'x': doAction('reject'); break;
|
||||||
|
case 'u': doAction('requeue'); break;
|
||||||
|
case 'r': startRun(); break;
|
||||||
|
case 'S': { const res = aq(['stop']); setFlash(c('red', '■ ') + (lastLine(res.out) || 'stopped')); break; }
|
||||||
|
default:
|
||||||
|
if (/^[1-9]$/.test(key)) {
|
||||||
|
const i = parseInt(key, 10) - 1;
|
||||||
|
if (i < items.length) { selIdx = i; selJob = items[i].job; }
|
||||||
|
} else { return; } // ignore unknown keys (no redraw)
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (INTERACTIVE) {
|
||||||
process.stdin.setRawMode(true);
|
process.stdin.setRawMode(true);
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
process.stdin.setEncoding('utf8');
|
process.stdin.setEncoding('utf8');
|
||||||
process.stdin.on('data', (key) => {
|
process.stdin.on('data', onKey);
|
||||||
if (key === 'q' || key === '\u0003') quit(); // q or Ctrl-C
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
process.on('SIGINT', quit);
|
process.on('SIGINT', quit);
|
||||||
process.on('SIGTERM', quit);
|
process.on('SIGTERM', quit);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user