@rse/ase 2.0.6 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dst/ase-agent-definition-api.d.ts +2 -0
  2. package/dst/ase-agent-definition-core.js +24 -3
  3. package/dst/ase-agent-fleet.js +2 -1
  4. package/dst/ase-agent-relay-client.js +3 -4
  5. package/dst/ase-agent-runtime-engine.js +22 -4
  6. package/dst/ase-agent-runtime-tool.js +10 -8
  7. package/dst/ase-agent-runtime.js +8 -4
  8. package/dst/ase-config-schema.js +4 -1
  9. package/dst/ase-lib-fetch.js +12 -0
  10. package/dst/ase-lib-server-cli.js +8 -5
  11. package/dst/ase-man-catalog.js +1 -1
  12. package/dst/ase-task-board-diff.js +120 -0
  13. package/dst/ase-task-board-tui-control.js +8 -5
  14. package/dst/ase-task-board-tui-model.js +54 -14
  15. package/dst/ase-task-board-tui-popup.js +41 -8
  16. package/dst/ase-task-board-tui.js +42 -27
  17. package/dst/ase-task-board-web-client.css +1 -1
  18. package/dst/ase-task-board-web-client.html +16 -0
  19. package/dst/ase-task-board-web-client.js +33 -33
  20. package/dst/ase-task-board-web-server.js +44 -9
  21. package/dst/ase-task-board.js +12 -4
  22. package/dst/ase-task-store-client.js +4 -5
  23. package/dst/ase-task-store-plugin-gitea.js +3 -3
  24. package/dst/man/ase-agent-adhoc.md +25 -6
  25. package/dst/man/ase-agent-condenser.md +7 -0
  26. package/dst/man/ase-agent-editor.md +7 -0
  27. package/dst/man/ase-agent-estimator.md +7 -0
  28. package/dst/man/ase-agent-fleet.md +7 -4
  29. package/dst/man/ase-agent-implementer.md +7 -0
  30. package/dst/man/ase-agent-integrator.md +7 -0
  31. package/dst/man/ase-agent-qualifier.md +7 -0
  32. package/dst/man/ase-agent-rebooter.md +7 -0
  33. package/dst/man/ase-agent-researcher.md +7 -0
  34. package/dst/man/ase-config-init.md +2 -1
  35. package/dst/man/ase-config.md +5 -0
  36. package/dst/man/ase-task-board.md +44 -2
  37. package/package.json +11 -10
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/.codex-plugin/plugin.json +1 -1
  40. package/plugin/.github/plugin/plugin.json +1 -1
  41. package/plugin/meta/ase-common-task.md +33 -1
  42. package/plugin/meta/ase-control.md +14 -0
  43. package/plugin/meta/ase-format-specbook.yaml +9 -9
  44. package/plugin/meta/ase-skill.md +7 -0
  45. package/plugin/mods/ase-mods-common.ts +73 -42
  46. package/plugin/mods/ase-mods-help-hint.ts +1 -1
  47. package/plugin/mods/ase-mods-task-edit.ts +427 -0
  48. package/plugin/mods/ase-mods.ts +45 -14
  49. package/plugin/skills/ase-code-edit/SKILL.md +53 -16
  50. package/plugin/skills/ase-code-edit/help.md +26 -13
  51. package/plugin/skills/ase-code-lint/SKILL.md +7 -3
  52. package/plugin/skills/ase-help-intent/data.md +248 -36
  53. package/plugin/skills/ase-help-skill/catalog.md +1 -0
  54. package/plugin/skills/ase-meta-workflow/workflow.txt +2 -0
  55. package/plugin/skills/ase-repo-changelog/SKILL.md +29 -10
  56. package/plugin/skills/ase-repo-changelog/help.md +5 -1
  57. package/plugin/skills/ase-repo-commit/SKILL.md +4 -1
  58. package/plugin/skills/ase-repo-dissect/help.md +2 -1
  59. package/plugin/skills/ase-repo-stage/SKILL.md +459 -0
  60. package/plugin/skills/ase-repo-stage/help.md +120 -0
  61. package/plugin/skills/ase-spec-edit/SKILL.md +20 -12
  62. package/plugin/skills/ase-spec-edit/help.md +22 -14
  63. package/plugin/skills/ase-task-edit/SKILL.md +114 -9
  64. package/plugin/skills/ase-task-edit/help.md +60 -0
  65. package/plugin/skills/ase-task-grill/SKILL.md +35 -20
  66. package/plugin/skills/ase-task-grill/help.md +18 -8
  67. /package/plugin/mods/{ase-mods-evaluate.ts → ase-mods-meta-evaluate.ts} +0 -0
  68. /package/plugin/mods/{ase-mods-grill.ts → ase-mods-task-grill.ts} +0 -0
@@ -19,7 +19,7 @@ import { statsOf, statsRanges, effortScale } from "./ase-task-board-stats.js";
19
19
  import { layoutGraph } from "./ase-task-board-graph.js";
20
20
  import { flowOf, layoutFlow, flowState } from "./ase-task-board-flow.js";
21
21
  import { fitGroups, agentLogWidth, agentOpLines, agentOpHeight, AGENT_INFO_H } from "./ase-task-board-tui-view.js";
22
- import { dialogTabs, planLines, attachmentLines, agentLogLines, agentLogKey, DIALOG_CHROME, DIALOG_WIDTH } from "./ase-task-board-tui-popup.js";
22
+ import { dialogTabs, planLines, planChanged, planDiffLines, attachmentLines, agentLogLines, agentLogKey, DIALOG_CHROME, DIALOG_WIDTH } from "./ase-task-board-tui-popup.js";
23
23
  import { pulseFrames } from "./ase-task-board-tui-style.js";
24
24
  /* the suggested request names of a request by prompt, matching its request name as last
25
25
  typed (all of them for an empty or an exactly suggested request name, to offer the
@@ -407,13 +407,14 @@ const usePromptActions = (ctx) => {
407
407
  };
408
408
  return { startPrompt, startRequest, cancelPrompt, submitPrompt };
409
409
  };
410
- /* the state of the terminal board: all state, the derived values, and the actions */
411
- export const useBoardState = (log, initial, relay) => {
410
+ /* the state of the terminal board (resp. with a task id, of the
411
+ task viewer): all state, the derived values, and the actions */
412
+ export const useBoardState = (log, initial, relay, watched) => {
412
413
  const { exit, suspendTerminal } = useApp();
413
414
  const { columns, rows } = useWindowSize();
414
415
  const [all, setBoard] = React.useState(initial);
415
416
  const [surface, setSurface] = React.useState(() => BoardState.load().tui);
416
- const [view, setView] = React.useState(() => surface.view);
417
+ const [view, setView] = React.useState(() => watched !== undefined ? "lanes" : surface.view);
417
418
  const [filter, setFilter] = React.useState("");
418
419
  const [typing, setTyping] = React.useState(false);
419
420
  /* the shown board: all tasks reduced onto the ones matching the filter
@@ -424,7 +425,7 @@ export const useBoardState = (log, initial, relay) => {
424
425
  return view === "graph" && !surface.standalone ? dropStandalone(found) : found;
425
426
  }, [all, filter, view, surface.standalone]);
426
427
  const [sel, setSel] = React.useState(() => relocate(board, { g: 0, l: 0, id: "" }, surface));
427
- const [dialog, setDialog] = React.useState(null);
428
+ const [dialog, setDialog] = React.useState(watched !== undefined ? { id: watched, tab: 0, first: 0, scrolls: {} } : null);
428
429
  const [plan, setPlan] = React.useState(null);
429
430
  const [files, setFiles] = React.useState(new Map());
430
431
  const [first, setFirst] = React.useState(0);
@@ -480,6 +481,8 @@ export const useBoardState = (log, initial, relay) => {
480
481
  }, [log, all]);
481
482
  /* persist the shown view for the next start of the terminal board */
482
483
  React.useEffect(() => {
484
+ if (watched !== undefined)
485
+ return;
483
486
  BoardState.setView("tui", view).catch((err) => {
484
487
  log.write("warning", `board: persisting view failed: ${err instanceof Error ? err.message : String(err)}`);
485
488
  });
@@ -523,6 +526,21 @@ export const useBoardState = (log, initial, relay) => {
523
526
  setNotice(`editing task "${prompt.id}" by prompt cancelled: task vanished in the meantime`);
524
527
  }
525
528
  }, [all, prompt]);
529
+ /* quit the task viewer once its dialog is closed, resp. with a warning once
530
+ its task vanished (except while an edit may rename it, which the dialog follows) */
531
+ const seen = React.useRef("");
532
+ React.useEffect(() => {
533
+ if (watched === undefined)
534
+ return;
535
+ if (dialog === null)
536
+ exit();
537
+ else if (all.cards.has(dialog.id))
538
+ seen.current = dialog.id;
539
+ else if (seen.current === dialog.id && !editing.current) {
540
+ log.write("warning", `board: task "${dialog.id}" no longer exists`);
541
+ exit();
542
+ }
543
+ }, [all, dialog?.id]);
526
544
  /* remember the box of every graph node for the spatial navigation and the scrolling */
527
545
  const places = React.useMemo(() => placesOf(layout?.graph), [layout]);
528
546
  /* follow changes of the task storage and of the lifecycle mode,
@@ -624,18 +642,26 @@ export const useBoardState = (log, initial, relay) => {
624
642
  };
625
643
  /* fetch the plan (with its attachments) of the read dialog whenever it is opened or the board changes
626
644
  (the latter in the background, i.e. without the busy popup), together with loading the
627
- syntax highlighter of YAML or JSON attachments (on the first opening only) */
645
+ syntax highlighter of YAML or JSON attachments (on the first opening only), where a
646
+ change with a shown effect remembers its predecessor for the diff of the read dialog,
647
+ and where closing the dialog forgets the plan, as its changes are followed only while open */
628
648
  const dialogId = dialog?.id;
629
649
  const dialogTab = dialog?.tab ?? 0;
630
650
  React.useEffect(() => {
631
- if (dialogId === undefined)
651
+ if (dialogId === undefined) {
652
+ setPlan(null);
632
653
  return;
654
+ }
633
655
  let live = true;
634
656
  const load = Promise.all([Task.parts(log, dialogId), Task.attachments(log, dialogId), dataLoad()]);
635
657
  const run = plan?.id === dialogId ? load : track({ text: "loading task", id: dialogId }, load);
636
658
  run.then(([parts, atts]) => {
637
659
  if (live)
638
- setPlan({ id: dialogId, parts: parts === null ? null : { ...parts, atts } });
660
+ setPlan((prev) => {
661
+ const next = parts === null ? null : { ...parts, atts };
662
+ const same = prev?.id === dialogId && next !== null;
663
+ return { id: dialogId, parts: next, before: !same ? undefined : planChanged(prev.parts, next) ? prev.parts : prev.before };
664
+ });
639
665
  }).catch((err) => {
640
666
  log.write("warning", `board: loading plan failed: ${err instanceof Error ? err.message : String(err)}`);
641
667
  if (live)
@@ -708,6 +734,12 @@ export const useBoardState = (log, initial, relay) => {
708
734
  const ref = process.platform === "win32" ? "\"%ASE_TASK_FILE%\"" : "\"$ASE_TASK_FILE\"";
709
735
  await suspendTerminal(async () => {
710
736
  mouseReporting(false);
737
+ /* stop the read of stdin still pending from Ink, which only removes its
738
+ listener, as it else takes the next input away from the editor and
739
+ passes it to the board afterwards (a pause in non-flowing mode does
740
+ not emit the event on which Node stops reading, and the stop is deferred) */
741
+ process.stdin.emit("pause");
742
+ await new Promise((resolve) => { process.nextTick(resolve); });
711
743
  try {
712
744
  await execa(`${editor} ${ref}`, { shell: true, stdio: "inherit", env: { ASE_TASK_FILE: file } });
713
745
  }
@@ -791,9 +823,10 @@ export const useBoardState = (log, initial, relay) => {
791
823
  setNotice(`creating task failed: ${msg} (press "N" to re-edit, or empty it to discard)`);
792
824
  }
793
825
  };
794
- /* delete a task (after its confirmation), closing its read dialog */
826
+ /* delete a task (after its confirmation), closing its read dialog
827
+ (in the task viewer only once it vanished, as closing it quits the process) */
795
828
  const remove = (id) => {
796
- if (dialog?.id === id)
829
+ if (dialog?.id === id && watched === undefined)
797
830
  setDialog(null);
798
831
  track({ text: "deleting task", id }, Task.delete(log, id)).then((existed) => {
799
832
  setNotice(existed ? `task "${id}" deleted` : `task "${id}" no longer exists`);
@@ -910,16 +943,23 @@ export const useBoardState = (log, initial, relay) => {
910
943
  const { agentList, agentIdx, agent, agentOp, logLines, logH, logFirst, logMax, selectAgent, scrollLog, focusAgent } = useAgentsView(agents, columns, boardH);
911
944
  /* the tabs of the read dialog, the lines of its selected tab (clamped to
912
945
  the existing tabs), and the maximum scroll offset of these lines */
913
- const dialogW = Math.min(columns - 2, DIALOG_WIDTH);
946
+ const dialogW = watched !== undefined ? columns : Math.min(columns - 2, DIALOG_WIDTH);
914
947
  const dialogParts = dialog !== null && plan?.id === dialog.id ? plan.parts : undefined;
915
948
  const tabLabels = dialogTabs(dialogParts);
916
949
  const dialogSel = Math.min(dialogTab, tabLabels.length - 1);
917
950
  const dialogAtt = dialogSel > 0 && dialogParts !== undefined && dialogParts !== null && !(dialogParts instanceof Error) ?
918
951
  dialogParts.atts[dialogSel - 1] : undefined;
952
+ const planDiff = React.useMemo(() => plan?.before !== undefined ?
953
+ planDiffLines(plan.before, plan.parts, plan.id, Math.max(1, dialogW - 5)) : null, [plan, dialogW]);
954
+ const dialogDiff = planDiff !== null && dialogSel === 0 && plan?.id === dialog?.id;
919
955
  const dialogLines = dialog === null ? [] : dialogAtt !== undefined ?
920
956
  attachmentLines(dialogAtt, files.get(`${dialog.id}:${dialogSel}`), Math.max(1, dialogW - 5)) :
921
- planLines(dialogParts, dialog.id, Math.max(1, dialogW - 5));
957
+ dialogDiff ? planDiff : planLines(dialogParts, dialog.id, Math.max(1, dialogW - 5));
922
958
  const dialogMax = Math.max(0, dialogLines.length - (rows - DIALOG_CHROME));
959
+ /* drop the diff of the last change from the read dialog, so just the latest state remains */
960
+ const clearDiff = () => {
961
+ setPlan((prev) => prev !== null ? { ...prev, before: undefined } : prev);
962
+ };
923
963
  /* the entries of the transfer popup of its (still existing) task */
924
964
  const transferCard = transfer !== null ? all.cards.get(transfer.id) : undefined;
925
965
  const transferList = transferCard !== undefined && cycle !== null ? transferEntries(all, cycle, transferCard) : [];
@@ -1015,11 +1055,11 @@ export const useBoardState = (log, initial, relay) => {
1015
1055
  /* the wall-clock and the heartbeats of the agents (for the agents view and the agent graph) */
1016
1056
  const { now, beatOf } = useBeats(agents, view);
1017
1057
  return {
1018
- log, exit, columns, rows, all, board, surface, view, setView, filter, setFilter, typing, setTyping,
1058
+ log, exit, watched, columns, rows, all, board, surface, view, setView, filter, setFilter, typing, setTyping,
1019
1059
  sel, setSel, dialog, setDialog, scroll, layout, notice, setNotice, carry, setCarry, cycle,
1020
1060
  confirm, setConfirm, transfer, setTransfer, assign, setAssign, prompt, setPrompt, mouse, setMouse, lastClick, grown, setGrow,
1021
1061
  cardBoxes, laneBoxes, groupBoxes, headBoxes, agentBoxes, rangeBoxes, cardRef, laneRef, groupRef, headRef, agentRef, rangeRef, boxAt, graphView,
1022
- places, graphTitles, boardH, dialogW, tabLabels, dialogSel, dialogLines, dialogScroll,
1062
+ places, graphTitles, boardH, dialogW, tabLabels, dialogSel, dialogLines, dialogScroll, dialogDiff, clearDiff,
1023
1063
  transferCard, transferList, fit, viewH, viewW, x, y, nodes, dim, pulse, now, beatOf, store, busy, busyTick,
1024
1064
  agents, working, cycleView, agentList, agentIdx, agent, agentOp, logLines, logH, logFirst, logMax, selectAgent, scrollLog, stats, usage, range, setRange, stepRange,
1025
1065
  flow, flowLayout, flowSel, setFlowSel, flowPlaces, focusAgent, scrollFlow,
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { Box, Text } from "ink";
7
7
  import { cardLabel, groupLabel, attachmentTabs, isDiff, diffTones, dataLang, dataSpans, clampLines } from "./ase-task-board-core.js";
8
+ import { diffHunks, changedWords } from "./ase-task-board-diff.js";
8
9
  import { h } from "./ase-task-board-tui-view.js";
9
10
  import { palette, cx, spinnerFrames, eighths } from "./ase-task-board-tui-style.js";
10
11
  const BOLD = 1;
@@ -13,6 +14,7 @@ const CODE = 4;
13
14
  const ACCENT = 8;
14
15
  const SIGNAL = 16;
15
16
  const DIM = 32;
17
+ const CHANGED = 64;
16
18
  /* strip the Markdown inline markers of a line ("`" for code, "**" and
17
19
  "__" for bold, "*" for italic), recording the style of the marked text */
18
20
  const emphasis = (line) => {
@@ -109,6 +111,35 @@ export const planLines = (parts, id, width) => {
109
111
  ...markdownLines(parts.body, width)
110
112
  ];
111
113
  };
114
+ /* determine whether a task plan differs from its predecessor in what the read
115
+ dialog shows (and not, for instance, only in its attachments) */
116
+ export const planChanged = (before, after) => {
117
+ const shown = (parts) => parts === undefined || parts === null || parts instanceof Error ? null :
118
+ JSON.stringify([[...parts.keys].filter(([key]) => key !== "Type"), parts.body]);
119
+ return shown(before) !== null && shown(after) !== null && shown(before) !== shown(after);
120
+ };
121
+ /* the lines of the diff of two task plans for the read dialog: the rendered lines of
122
+ both, where removed lines are red and added lines blue, with their sign in the
123
+ otherwise empty first column and their changed words marked, and where only the
124
+ shown content of the lines decides about a change */
125
+ export const planDiffLines = (before, after, id, width) => {
126
+ const changed = (line, sign, tint, spans) => {
127
+ const mask = [...(line.mask ?? new Array(line.text.length).fill(0))];
128
+ for (const { from, to } of spans)
129
+ for (let k = from; k < to; k++)
130
+ mask[k] |= CHANGED;
131
+ return { ...line, mask, sign, tint };
132
+ };
133
+ return diffHunks(planLines(before, id, width), planLines(after, id, width), (line) => JSON.stringify(line))
134
+ .flatMap(({ kept, removed, added }) => {
135
+ const [del, add] = changedWords(removed.map((line) => line.text), added.map((line) => line.text));
136
+ return [
137
+ ...kept,
138
+ ...removed.map((line, k) => changed(line, "-", "red", del[k])),
139
+ ...added.map((line, k) => changed(line, "+", "blue", add[k]))
140
+ ];
141
+ });
142
+ };
112
143
  /* the lines of a task plan attachment for the read dialog: its embedded
113
144
  data or its (lazily loaded) file content, rendered as Markdown for a
114
145
  Markdown type, syntax-highlighted for a YAML or JSON type, colored per
@@ -549,8 +580,7 @@ export const renderAssign = (assign, choices, unknown, columns, rows) => {
549
580
  };
550
581
  /* render the read dialog: full height, horizontally centered, with a
551
582
  vertical scroll bar in its right border */
552
- export const renderDialog = ({ card, group, id, pred, succ, tint, tabs, tab, first, scroll, lines, columns, rows, notice }) => {
553
- const width = Math.min(columns - 2, DIALOG_WIDTH);
583
+ export const renderDialog = ({ card, group, id, pred, succ, tint, tabs, tab, first, scroll, lines, width, columns, rows, notice, viewer, diffed }) => {
554
584
  const left = Math.floor((columns - width) / 2);
555
585
  const bodyW = width - 5;
556
586
  const viewH = rows - DIALOG_CHROME;
@@ -599,14 +629,16 @@ export const renderDialog = ({ card, group, id, pred, succ, tint, tabs, tab, fir
599
629
  /* the key hints, or instead the status notice of an edit, truncated to the free width */
600
630
  const free = Math.max(0, innerW - pos.length - 1);
601
631
  const keys = (notice !== null ? " " + sanitize(notice) :
602
- " ←/→/⇤/⇥: switch tab · ↑/↓/⇈/⇊: scroll · e: edit · E: edit by prompt · R: request by prompt · T: transition · A: assign · D: delete · M: toggle mouse · ⏎/ESC: close").slice(0, free);
603
- return h(Box, { key: "dialog", top: 0, left, width, height: rows, ...cx("popup", "border-dim") }, h(Text, {}, " ", ...(prefix !== "" ? [h(Text, { ...cx("bold"), color: tint(id) }, prefix)] : []), h(Text, { ...cx("badge"), color: tint(id) }, ` ${epic ? "♛ " : ""}≡ ${head.slice(0, id.length)} `), h(Text, { color: tint(id) }, head.slice(id.length)), h(Text, cx("dim"), right.slice(0, right.length - lane.length)), h(Text, cx("dim", "bold"), lane), " ", h(Text, cx("dim", "inverse"), " X "), " "), separator("sep-title"), h(Text, {}, ...segs.map((seg, k) => h(Text, { key: k, color: seg.color, inverse: seg.inverse }, seg.text))), separator("sep-head"), h(Text, { wrap: "truncate" }, ...tabBar.map((seg, k) => h(Text, { key: k, color: seg.color, bold: seg.bold, inverse: seg.inverse }, seg.text))), separator("sep-tabs"), ...Array.from({ length: viewH }, (_, i) => {
632
+ (diffed ? " #: clear diff ·" : "") +
633
+ " ←/→/⇤/⇥: switch tab · ↑/↓/⇈/⇊: scroll · e: edit · E: edit by prompt · R: request by prompt · T: transition · A: assign · D: delete · M: toggle mouse · " +
634
+ (viewer ? "q: quit" : "⏎/ESC: close")).slice(0, free);
635
+ return h(Box, { key: "dialog", top: 0, left, width, height: rows, ...cx("popup", "border-dim") }, h(Text, {}, " ", ...(prefix !== "" ? [h(Text, { ...cx("bold"), color: tint(id) }, prefix)] : []), h(Text, { ...cx("badge"), color: tint(id) }, ` ${epic ? "♛ " : ""}≡ ${head.slice(0, id.length)} `), h(Text, { color: tint(id) }, head.slice(id.length)), h(Text, cx("dim"), right.slice(0, right.length - lane.length)), h(Text, cx("dim", "bold"), lane), " ", h(Text, cx("dim", !viewer && "inverse"), viewer ? " " : " X "), " "), separator("sep-title"), h(Text, {}, ...segs.map((seg, k) => h(Text, { key: k, color: seg.color, inverse: seg.inverse }, seg.text))), separator("sep-head"), h(Text, { wrap: "truncate" }, ...tabBar.map((seg, k) => h(Text, { key: k, color: seg.color, bold: seg.bold, inverse: seg.inverse }, seg.text))), separator("sep-tabs"), ...Array.from({ length: viewH }, (_, i) => {
604
636
  const line = lines[s + i];
605
637
  const bar = lines.length <= viewH ? " " : (i >= thumbY && i < thumbY + thumbH ? "█" : "░");
606
638
  /* split the padded line into runs of equal inline style (cut to the body width,
607
639
  as a wider line would wrap and push the rows of the dialog out of place) */
608
- const text = " " + (line?.text ?? "").slice(0, bodyW).padEnd(bodyW);
609
- const mask = [0, ...(line?.mask ?? [])];
640
+ const text = (line?.sign ?? " ") + (line?.text ?? "").slice(0, bodyW).padEnd(bodyW);
641
+ const mask = [line?.sign !== undefined ? CHANGED : 0, ...(line?.mask ?? [])];
610
642
  const runs = [];
611
643
  for (let k = 0; k < text.length; k++) {
612
644
  const style = mask[k] ?? 0;
@@ -617,8 +649,9 @@ export const renderDialog = ({ card, group, id, pred, succ, tint, tabs, tab, fir
617
649
  }
618
650
  return h(Box, { key: i }, ...runs.map((run, k) => h(Text, {
619
651
  key: k, wrap: "truncate",
620
- color: (run.style & (CODE | ACCENT)) !== 0 ? palette.accent : (run.style & SIGNAL) !== 0 ? palette.signal :
621
- (run.style & DIM) !== 0 ? palette.dim : line?.color,
652
+ color: line?.tint ?? ((run.style & (CODE | ACCENT)) !== 0 ? palette.accent : (run.style & SIGNAL) !== 0 ? palette.signal :
653
+ (run.style & DIM) !== 0 ? palette.dim : line?.color),
654
+ inverse: (run.style & CHANGED) !== 0,
622
655
  bold: (line?.bold ?? false) || (run.style & BOLD) !== 0, italic: (run.style & ITALIC) !== 0
623
656
  }, run.text)), h(Text, cx("signal"), ` ${bar}`));
624
657
  }), separator("sep-foot"), h(Box, {}, h(Text, cx("hint", notice !== null && "signal"), keys), h(Text, cx("hint"), pos.padStart(Math.max(0, innerW - keys.length)))));
@@ -16,10 +16,45 @@ import { palette, loadPalette, cx } from "./ase-task-board-tui-style.js";
16
16
  import { sanitize, renderDialog, renderConfirm, renderTransfer, renderAssign, renderPrompt, renderBusy } from "./ase-task-board-tui-popup.js";
17
17
  /* render the whole screen */
18
18
  const renderScreen = (ctx, project) => {
19
- const { columns, rows, all, board, surface, view, filter, typing, dialog, notice, carry, confirm, transfer, assign, prompt, headRef, tabLabels, dialogSel, dialogLines, transferCard, transferList, dim, store, busy, busyTick, agents } = ctx;
19
+ const { columns, rows, all, board, surface, view, filter, typing, dialog, notice, carry, confirm, transfer, assign, prompt, headRef, tabLabels, dialogW, dialogSel, dialogLines, dialogDiff, transferCard, transferList, dim, store, busy, busyTick, agents, watched } = ctx;
20
20
  /* refuse to draw into a too small window */
21
21
  if (columns < 40 || rows < 16)
22
22
  return h(Box, { width: columns, height: rows, justifyContent: "center", alignItems: "center" }, h(Text, cx("signal"), `window too small (${columns}×${rows}) — needs at least 40×16`));
23
+ /* the dialog and popups on top of the screen */
24
+ const overlays = [
25
+ dialog !== null ? renderDialog({
26
+ card: all.cards.get(dialog.id),
27
+ group: all.groups.find((g) => g.lanes.some((l) => l.status === all.cards.get(dialog.id)?.status))?.title,
28
+ id: dialog.id,
29
+ pred: all.pred.get(dialog.id) ?? [],
30
+ succ: all.succ.get(dialog.id) ?? [],
31
+ tint: (ref) => {
32
+ const c = all.cards.get(ref);
33
+ const tone = c !== undefined ? toneOf(all, c) : "idle";
34
+ return tone === "active" ? palette.accent : tone === "done" ? palette.dim : palette.normal;
35
+ },
36
+ tabs: tabLabels,
37
+ tab: dialogSel,
38
+ first: dialog.first,
39
+ scroll: dialog.scrolls[dialogSel] ?? 0,
40
+ lines: dialogLines,
41
+ width: dialogW,
42
+ columns,
43
+ rows,
44
+ notice,
45
+ viewer: watched !== undefined,
46
+ diffed: dialogDiff
47
+ }) : null,
48
+ confirm !== null ? renderConfirm(confirm.id, confirm.yes, columns, rows) : null,
49
+ transfer !== null && transferCard !== undefined ?
50
+ renderTransfer(transfer.id, transferCard.status, transferList, transfer.at, columns, rows) : null,
51
+ assign !== null ? renderAssign(assign, assignChoices(assign), assignUnknown(assign), columns, rows) : null,
52
+ prompt !== null ? renderPrompt(prompt, columns, rows, prompt.request !== null ? requestChoices(prompt.request) : []) : null,
53
+ busy !== null ? renderBusy(busy.label, busy.since, busyTick, columns, rows) : null
54
+ ];
55
+ /* the task viewer shows only the dialog and popups, without the board */
56
+ if (watched !== undefined)
57
+ return h(Box, { width: columns, height: rows, flexDirection: "column" }, ...overlays);
23
58
  /* the status line in the last line of the screen, below the key hints, enclosed
24
59
  on its left and right side by the downward lines of the rule above it,
25
60
  showing the kind and connection state of the task store (a disconnected one in
@@ -59,27 +94,7 @@ const renderScreen = (ctx, project) => {
59
94
  view === "flow" ? renderFlow(ctx) : renderAgents(ctx))
60
95
  .filter((el) => surface.keys || !String(el.key).startsWith("keys")),
61
96
  /* the horizontal rule between the key hints and the status line, turning downward with rounded corners at both ends */
62
- h(Text, cx("dim", dim && "dimmed"), " ╭" + "─".repeat(Math.max(0, columns - 4)) + "╮ "), status, dialog !== null ? renderDialog({
63
- card: all.cards.get(dialog.id),
64
- group: all.groups.find((g) => g.lanes.some((l) => l.status === all.cards.get(dialog.id)?.status))?.title,
65
- id: dialog.id,
66
- pred: all.pred.get(dialog.id) ?? [],
67
- succ: all.succ.get(dialog.id) ?? [],
68
- tint: (ref) => {
69
- const c = all.cards.get(ref);
70
- const tone = c !== undefined ? toneOf(all, c) : "idle";
71
- return tone === "active" ? palette.accent : tone === "done" ? palette.dim : palette.normal;
72
- },
73
- tabs: tabLabels,
74
- tab: dialogSel,
75
- first: dialog.first,
76
- scroll: dialog.scrolls[dialogSel] ?? 0,
77
- lines: dialogLines,
78
- columns,
79
- rows,
80
- notice
81
- }) : null, confirm !== null ? renderConfirm(confirm.id, confirm.yes, columns, rows) : null, transfer !== null && transferCard !== undefined ?
82
- renderTransfer(transfer.id, transferCard.status, transferList, transfer.at, columns, rows) : null, assign !== null ? renderAssign(assign, assignChoices(assign), assignUnknown(assign), columns, rows) : null, prompt !== null ? renderPrompt(prompt, columns, rows, prompt.request !== null ? requestChoices(prompt.request) : []) : null, busy !== null ? renderBusy(busy.label, busy.since, busyTick, columns, rows) : null);
97
+ h(Text, cx("dim", dim && "dimmed"), " ╭" + "─".repeat(Math.max(0, columns - 4)) + "╮ "), status, ...overlays);
83
98
  };
84
99
  /* pass each pressed SGR mouse report ("ESC[<b;x;y" plus "M"/"m" for press/release, 1-based)
85
100
  with its 0-based column/row, read directly from stdin, as Ink 8 drops them before
@@ -138,8 +153,8 @@ export const useMouseReports = (onPress) => {
138
153
  }, [stdin]);
139
154
  };
140
155
  /* the root component of the terminal board */
141
- const App = ({ log, initial, project, relay }) => {
142
- const ctx = useBoardState(log, initial, relay);
156
+ const App = ({ log, initial, project, relay, watched }) => {
157
+ const ctx = useBoardState(log, initial, relay, watched);
143
158
  /* handle the mouse, but ignore it while the modal busy popup is shown */
144
159
  useMouseReports((button, col, row) => {
145
160
  if (ctx.busy === null)
@@ -180,8 +195,8 @@ const lateErasing = (stream) => {
180
195
  }
181
196
  });
182
197
  };
183
- /* run the terminal board until the user quits */
184
- export const runTUI = async (log, relay) => {
198
+ /* run the terminal board (resp. with a task id, the task viewer) until the user quits */
199
+ export const runTUI = async (log, relay, watched) => {
185
200
  const initial = await buildBoard(log);
186
201
  const project = Task.projectIdOf(Task.projectRoot());
187
202
  loadPalette(log);
@@ -199,7 +214,7 @@ export const runTUI = async (log, relay) => {
199
214
  rewrite only the changed lines of a frame, as otherwise every frame
200
215
  is erased and redrawn entirely, which flickers in terminals not
201
216
  honoring synchronized output (DEC mode 2026), like tmux popups */
202
- const app = render(h(App, { log, initial, project, relay }), {
217
+ const app = render(h(App, { log, initial, project, relay, watched }), {
203
218
  alternateScreen: true, exitOnCtrlC: true, patchConsole: true, kittyKeyboard: { mode: "auto" },
204
219
  incrementalRendering: true, stdout: lateErasing(process.stdout)
205
220
  });