Skip to content

Commit 6c9f1fe

Browse files
committed
feat(admin): command triggers show /name across the TUI surfaces (#188)
Issue #189 carried run.command onto the display records and the graph trigger nodes for this issue's display half, but the TUI list target and the trigger drill-in still rendered a bare dash for a command trigger, as if it targeted nothing. The /name token now comes from one exported vocabulary, render.mjs commandSlashLabel, used by the triggers list line, the TUI target column and the drill-in header, so three renderings cannot drift on what a command trigger is called; the drill-in's runs section additionally carries the full /name-and-args line the reviewed file staged, because the detail pane is the operator's own session and the list keeps the name-only skimmable token. REQ-TOPOLOGY-GRAPH UNCHANGED, checked: no graph change in this slice. INT-TRIGGERS-FILE-CONTRACT UNCHANGED, checked: display only, the parse and the producer surfaces are untouched. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent 8702d3a commit 6c9f1fe

4 files changed

Lines changed: 46 additions & 10 deletions

File tree

admin/src/dashboard.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { makeQueue } from "@edgehero/pi-dispatch/queue";
2525
import { STALL_KEY } from "@edgehero/pi-dispatch/scheduler-stall-guard";
2626
import { windowEndAt } from "@edgehero/pi-dispatch/pause-windows";
2727
import { listRuns, readSettingsView, mapSchedulers, readTriggers, readPauseWindows, readStagedPackages } from "./read-model.mjs";
28-
import { renderStatus, renderBudget, renderTriggers, renderSettingsView } from "./render.mjs";
28+
import { renderStatus, renderBudget, renderTriggers, renderSettingsView, commandSlashLabel } from "./render.mjs";
2929
import { matchesKey } from "./keys.mjs";
3030
import { box, meter, clip, makeLineInput } from "./panel.mjs";
3131
import { makeStyler, frame, RULE } from "./style.mjs";
@@ -935,7 +935,10 @@ function matchColored(t: any, styler: any): string {
935935

936936
function targetColored(t: any, styler: any): string {
937937
const arrow = styler.fg("dim", "→");
938-
const flow = styler.bold(styler.fg("text", t?.flow ?? "-"));
938+
// A command trigger shows its `/name` in the flow position, through render.mjs's one exported
939+
// vocabulary (issue #188): before this fallback the row rendered a bare "-", as if the trigger
940+
// targeted nothing.
941+
const flow = styler.bold(styler.fg("text", t?.flow ?? commandSlashLabel(t) ?? "-"));
939942
if (t?.type === "cron") {
940943
// A local/cron trigger runs its flow against a folder — show `local <folder>/<flow>` so the target
941944
// (not just the flow name) is visible; github triggers get their repo from the webhook, so none there.
@@ -1077,7 +1080,7 @@ function renderTriggerDetail(t: any, inner: number, styler: any, sched: any = nu
10771080

10781081
// Header: kind badge -> flow, plus a health marker for cron (✔ healthy / ⚠ overdue) derived from the
10791082
// scheduler's overdueMs. A trigger with no matching scheduler shows no health marker rather than a guess.
1080-
let header = styler.fg(KIND_COLOR[t.type] ?? "muted", t.type ?? "?") + " " + styler.bold(styler.fg("text", `→ ${t.flow ?? "-"}`));
1083+
let header = styler.fg(KIND_COLOR[t.type] ?? "muted", t.type ?? "?") + " " + styler.bold(styler.fg("text", `→ ${t.flow ?? commandSlashLabel(t) ?? "-"}`));
10811084
if (t.type === "cron" && sched) {
10821085
const healthy = !sched.overdueMs;
10831086
header += " " + (healthy ? styler.fg("success", "✔ healthy") : styler.fg("warning", `⚠ overdue ${formatDuration(sched.overdueMs)}`));
@@ -1123,6 +1126,11 @@ function renderTriggerDetail(t: any, inner: number, styler: any, sched: any = nu
11231126
out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
11241127
out.push(kv("model", "deployment default", "dim"));
11251128
}
1129+
// A command trigger's full `/name args` line (issue #189): the list and header show the name only,
1130+
// and this drill-in is where the args belong -- the reviewed file staged them, the operator's own
1131+
// session shows them. Rendered only when armed, on both branches, because all four kinds can carry
1132+
// a command; a flow trigger's pane stays byte-identical.
1133+
if (typeof t.command === "string" && t.command.trim() !== "") out.push(kv("command", `/${t.command.trim()}`, "accent"));
11261134
// Same shape as the model row -- a per-trigger override of a deployment default -- and rendered on BOTH
11271135
// branches, because unlike model, all four kinds can carry an image. The dim "deployment default" is
11281136
// deliberate rather than an omitted row: a missing row would read as "I don't know", this reads as

admin/src/render.mjs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,7 @@ export function renderTriggers({ schedulers, triggers } = {}) {
167167
* suffix is empty and appended last.
168168
*/
169169
function triggerLine(t) {
170-
// A command trigger (issue #189, `run.command`) shows `/name` in the flow position: the shared parser
171-
// makes flow and command mutually exclusive, so the column never has to hold both, and the slash marks
172-
// "dispatches a registered extension command" apart from a flow at a glance. The NAME only (the first
173-
// space-delimited token) so the line stays skimmable, the [skills basename] doctrine restated; the args
174-
// belong in the detail view.
175-
const flow = t?.flow ?? (typeof t?.command === "string" && t.command.trim() !== "" ? `/${t.command.trim().split(/\s+/)[0]}` : "-");
170+
const flow = t?.flow ?? commandSlashLabel(t) ?? "-";
176171
const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
177172
const pkgs = t?.packages === true ? " [packages]" : "";
178173
const img = t?.image ? ` [image ${t.image}]` : "";
@@ -210,6 +205,19 @@ function triggerLine(t) {
210205
}
211206
}
212207

208+
/**
209+
* The `/name` display token for a command trigger (issue #189, `run.command`), or null when the entry
210+
* carries no command. It renders in the flow position: the shared parser makes flow and command mutually
211+
* exclusive, so the column never has to hold both, and the slash marks "dispatches a registered extension
212+
* command" apart from a flow at a glance. The NAME only (the first space-delimited token, pi's own
213+
* dispatch grammar) so the line stays skimmable, the [skills basename] doctrine restated; the args belong
214+
* in the detail view. Exported as the one vocabulary (issue #188): the list line here, the TUI's target
215+
* column and the drill-in header must not drift on what a command trigger is called.
216+
*/
217+
export function commandSlashLabel(t) {
218+
return typeof t?.command === "string" && t.command.trim() !== "" ? `/${t.command.trim().split(/\s+/)[0]}` : null;
219+
}
220+
213221
function ruleClauses(rule) {
214222
const clauses = [];
215223
for (const key of ["any", "all", "none"]) {

admin/test/dashboard.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,16 @@ async function renderTrigger(trigger) {
768768
return { list, detail: stripAnsi(comp.render(100).join("\n")) };
769769
}
770770

771+
test("a command trigger's target and drill-in show /name, and the full staged line lives in the detail", async () => {
772+
// Before this, both surfaces rendered a bare "-" for a command trigger, as if it targeted nothing
773+
// (issue #188's display half). The /name comes from render.mjs's one exported vocabulary.
774+
const { list, detail } = await renderTrigger({ type: "comment", forge: "github", phrase: "@pi deploy", flow: null, command: "deploy prod --now", packages: false, image: null, skillsDir: null, instructions: false, resume: false, replicas: null });
775+
assert.ok(list.includes("/deploy"), "the target column shows the slash name, not '-'");
776+
assert.equal(list.includes("deploy prod --now"), false, "the args stay out of the list row");
777+
assert.ok(detail.includes("→ /deploy"), "the drill-in header names the command the way it names a flow");
778+
assert.ok(detail.includes("/deploy prod --now"), "the full staged line belongs in the detail, the operator's own session");
779+
});
780+
771781
test("a trigger's row names its forge ONCE -- the target says it, so no badge repeats it", async () => {
772782
// The badge existed because the target read `-> github` for every forge, so a gitlab row contradicted its
773783
// own badge. Fixing the target removed the badge's REASON to exist, not merely its wrongness. render.mjs

admin/test/render.test.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { test } from "node:test";
22
import assert from "node:assert/strict";
33
import { readFileSync } from "node:fs";
44
import { fileURLToPath } from "node:url";
5-
import { renderStatus, renderRuns, renderBudget, renderTriggers, renderSettingsView, renderWhatIf } from "../src/render.mjs";
5+
import { renderStatus, renderRuns, renderBudget, renderTriggers, renderSettingsView, renderWhatIf, commandSlashLabel } from "../src/render.mjs";
66

77
test("render.mjs has no path to raw .log content", () => {
88
const src = readFileSync(fileURLToPath(new URL("../src/render.mjs", import.meta.url)), "utf8");
@@ -165,6 +165,16 @@ test("renderTriggers shows a command trigger as /name in the flow position; flow
165165
assert.match(neither, /comment {2}"@pi deploy" -$/m);
166166
});
167167

168+
test("commandSlashLabel is the one /name vocabulary every surface shares (issue #188)", () => {
169+
// Exported so the list line here, the TUI's target column and the drill-in header cannot drift on
170+
// what a command trigger is called: three renderings, one token rule.
171+
assert.equal(commandSlashLabel({ command: "deploy prod --now" }), "/deploy");
172+
assert.equal(commandSlashLabel({ command: " wf run " }), "/wf", "surrounding whitespace never reaches the token");
173+
assert.equal(commandSlashLabel({ command: "" }), null);
174+
assert.equal(commandSlashLabel({ flow: "fix" }), null, "a flow trigger has no slash label");
175+
assert.equal(commandSlashLabel(null), null);
176+
});
177+
168178
test("renderTriggers marks a packages-loading trigger and leaves a declining one unchanged", () => {
169179
const cron = (packages) => ({ type: "cron", id: "nightly", pattern: "0 3 * * *", folder: "/srv/p", flow: "tidy", packages });
170180
// `true` is what normalizeTriggerForDisplay yields for a trigger that OMITS run.packages -- the common

0 commit comments

Comments
 (0)