@cueloop/client 0.1.0-alpha.39 → 0.1.0-alpha.40

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.39",
3
+ "version": "0.1.0-alpha.40",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -25,10 +25,10 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "@cueloop/daemon": "0.1.0-alpha.39",
29
- "@cueloop/extension-api": "0.1.0-alpha.39",
30
- "@cueloop/integration-obsidian": "0.1.0-alpha.39",
31
- "@cueloop/schema": "0.1.0-alpha.39",
28
+ "@cueloop/daemon": "0.1.0-alpha.40",
29
+ "@cueloop/extension-api": "0.1.0-alpha.40",
30
+ "@cueloop/integration-obsidian": "0.1.0-alpha.40",
31
+ "@cueloop/schema": "0.1.0-alpha.40",
32
32
  "@opentui/core": "^0.5.2",
33
33
  "@opentui/keymap": "0.5.2",
34
34
  "@opentui/react": "^0.5.2",
@@ -1,7 +1,7 @@
1
1
  /** The plan review surface v2: native selection feeds the annotation quote, the inline compose box keeps its anchor painted, the rail edits what the document selects, and edit-exit reconciliation orphans annotations whose passage was removed. Char-frame + styled-span assertions over the real App and a real in-process daemon. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
- import { mkdtempSync, rmSync } from "node:fs";
4
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
@@ -525,7 +525,7 @@ describe("sheet header", () => {
525
525
  expect(frame).toContain("Share");
526
526
  });
527
527
 
528
- test("the Agent tab shows the submitter, status, and revision", async () => {
528
+ test("the Agent tab shows the harness launcher and a compact status footer", async () => {
529
529
  // Arrange
530
530
  const setup = await renderApp();
531
531
  const lines = setup.captureCharFrame().split("\n");
@@ -536,12 +536,39 @@ describe("sheet header", () => {
536
536
  await setup.mockMouse.click(tabColumn + 1, tabRow);
537
537
  await setup.renderOnce();
538
538
 
539
- // Assert
540
- await waitForText(setup, "status: pending");
539
+ // Assert - branded launcher cards, and the submitter kept in the footer
540
+ await waitForText(setup, "claude code");
541
541
  const frame = setup.captureCharFrame();
542
+ expect(frame).toContain("Ask an agent about this plan");
543
+ expect(frame).toContain("codex");
542
544
  expect(frame).toContain("agent/worker-3");
543
- expect(frame).toContain("status: pending");
544
- expect(frame).toContain("revision 1");
545
+ });
546
+ });
547
+
548
+ describe("quick-actions settings editor", () => {
549
+ test("expanding an action edits its system prompt inline and persists it", async () => {
550
+ // Arrange
551
+ const setup = await renderApp();
552
+ const clickText = async (needle: string): Promise<void> => {
553
+ const lines = setup.captureCharFrame().split("\n");
554
+ const row = lines.findIndex((line) => line.includes(needle));
555
+ await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
556
+ await setup.renderOnce();
557
+ };
558
+
559
+ // Act - open Settings, enter Actions, expand the first action, type into its input
560
+ await clickText("menu");
561
+ await clickText("Settings");
562
+ await clickText("Actions");
563
+ await clickText("Zoom out, research in depth");
564
+ await type(setup, "CUSTOM");
565
+ await setup.renderOnce();
566
+
567
+ // Assert - the keystrokes reached the focused input (not swallowed by nav) and persisted
568
+ expect(setup.captureCharFrame()).toContain("CUSTOM");
569
+ await waitForState(setup, () =>
570
+ readFileSync(join(home, "no-config.toml"), "utf8").includes("CUSTOM"),
571
+ );
545
572
  });
546
573
  });
547
574
 
package/src/App.tsx CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  DEFAULT_KEYS,
21
21
  DEFAULT_QUICK_ACTIONS,
22
22
  loadConfig,
23
+ persistActions,
23
24
  persistAuthorName,
24
25
  persistAutoClose,
25
26
  persistTheme,
@@ -37,6 +38,7 @@ import type { Theme } from "./theme";
37
38
  import { returnPaneFor } from "@cueloop/schema";
38
39
  import { createReviewController } from "./session-controller";
39
40
  import type { SessionClient } from "@cueloop/daemon/client";
41
+ import { launchHarnessInSplit } from "@cueloop/daemon/herdr-split";
40
42
  import {
41
43
  activeSpanState,
42
44
  createIntentDispatch,
@@ -51,6 +53,7 @@ import { Toolbar } from "./components/primitives/Toolbar";
51
53
  import { MenuBar } from "./components/MenuBar";
52
54
  import { KeybindsDialog } from "./components/KeybindsDialog";
53
55
  import { SettingsDialog, type SettingsCategory } from "./components/SettingsDialog";
56
+ import { QuickActionsEditor } from "./components/quick-actions-editor";
54
57
  import { CLIENT_VERSION } from "./version";
55
58
  import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
56
59
  import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
@@ -183,6 +186,8 @@ export function App({
183
186
  const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
184
187
  const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
185
188
  const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
189
+ // the quick-action row whose system-prompt input is open in Settings, or null
190
+ const [actionsExpandedIndex, setActionsExpandedIndex] = useState<number | null>(null);
186
191
  useEffect(() => {
187
192
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
188
193
  keysRef.current = config.keys;
@@ -415,6 +420,26 @@ export function App({
415
420
  : "none";
416
421
 
417
422
  // ── settings dialog: config-backed model, navigation, persistence ──
423
+ const commitActions = (next: QuickAction[]): void => {
424
+ setQuickActions(next);
425
+ persistActions(next);
426
+ };
427
+ const editActionMetadata = (index: number, metadata: string): void =>
428
+ commitActions(
429
+ quickActions.map((action, actionIndex) =>
430
+ actionIndex === index
431
+ ? { ...action, metadata: metadata.trim() ? metadata : undefined }
432
+ : action,
433
+ ),
434
+ );
435
+ const resetActions = (): void => {
436
+ setActionsExpandedIndex(null);
437
+ commitActions(DEFAULT_QUICK_ACTIONS.map((action) => ({ ...action })));
438
+ };
439
+ const addAction = (): void => {
440
+ setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: quickActions.length }));
441
+ commitActions([...quickActions, { prompt: "New action" }]);
442
+ };
418
443
  const settingsCategories: SettingsCategory[] = [
419
444
  {
420
445
  id: "general",
@@ -455,6 +480,27 @@ export function App({
455
480
  },
456
481
  ],
457
482
  },
483
+ {
484
+ id: "actions",
485
+ name: "Actions",
486
+ description: "quick-action comments",
487
+ rows: [],
488
+ customBody: (
489
+ <QuickActionsEditor
490
+ actions={quickActions}
491
+ selectedIndex={settingsNav.categoryId === "actions" ? settingsNav.rowIndex : -1}
492
+ expandedIndex={actionsExpandedIndex}
493
+ onToggleExpand={(index) => {
494
+ setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: index }));
495
+ setActionsExpandedIndex((current) => (current === index ? null : index));
496
+ }}
497
+ onEditMetadata={editActionMetadata}
498
+ onReset={resetActions}
499
+ onAdd={addAction}
500
+ theme={theme}
501
+ />
502
+ ),
503
+ },
458
504
  ];
459
505
  const settingsValues = {
460
506
  autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
@@ -479,6 +525,11 @@ export function App({
479
525
  }
480
526
  };
481
527
  const handleSettingsKey = (name: string): void => {
528
+ // an open system-prompt input owns typing; only esc (close it) escapes here
529
+ if (actionsExpandedIndex !== null) {
530
+ if (name === "escape") setActionsExpandedIndex(null);
531
+ return;
532
+ }
482
533
  if (name === "escape") return void setMenuDialog(null);
483
534
  const categoryIndex = settingsCategories.findIndex(
484
535
  (category) => category.id === settingsNav.categoryId,
@@ -502,6 +553,24 @@ export function App({
502
553
  setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: 0 }));
503
554
  return;
504
555
  }
556
+ // the Actions category is a list of quick actions plus a trailing "add" row
557
+ if (category.id === "actions") {
558
+ const rowCount = quickActions.length + 1;
559
+ if (name === "j" || name === "down")
560
+ setSettingsNav((state) => ({
561
+ ...state,
562
+ rowIndex: Math.min(rowCount - 1, state.rowIndex + 1),
563
+ }));
564
+ else if (name === "k" || name === "up")
565
+ setSettingsNav((state) => ({ ...state, rowIndex: Math.max(0, state.rowIndex - 1) }));
566
+ else if (name === "h" || name === "tab")
567
+ setSettingsNav((state) => ({ ...state, zone: "nav" }));
568
+ else if (name === "return" || name === "space" || name === "l") {
569
+ if (settingsNav.rowIndex === quickActions.length) addAction();
570
+ else setActionsExpandedIndex(settingsNav.rowIndex);
571
+ }
572
+ return;
573
+ }
505
574
  if (name === "j" || name === "down")
506
575
  setSettingsNav((state) => ({
507
576
  ...state,
@@ -582,7 +651,10 @@ export function App({
582
651
  activeCategoryId={settingsNav.categoryId}
583
652
  activeRowIndex={settingsNav.rowIndex}
584
653
  activeZone={settingsNav.zone}
585
- onCategorySelect={(id) => setSettingsNav({ categoryId: id, rowIndex: 0, zone: "body" })}
654
+ onCategorySelect={(id) => {
655
+ setActionsExpandedIndex(null);
656
+ setSettingsNav({ categoryId: id, rowIndex: 0, zone: "body" });
657
+ }}
586
658
  onRowActivate={(row) => cycleSetting(row.key)}
587
659
  theme={theme}
588
660
  />
@@ -989,6 +1061,12 @@ export function App({
989
1061
  onSelectCuration: selectCurationFromRail,
990
1062
  onUndoCuration: undoCurationFromRail,
991
1063
  onSubmitRequest,
1064
+ onLaunchHarness: (command, seedText) =>
1065
+ launchHarnessInSplit({
1066
+ command,
1067
+ cwd: activeSession.artifact.meta.cwd ?? process.cwd(),
1068
+ seedText,
1069
+ }),
992
1070
  }}
993
1071
  />
994
1072
  </box>
@@ -23,6 +23,7 @@ const rail = {
23
23
  onSelectCuration: () => {},
24
24
  onUndoCuration: () => {},
25
25
  onSubmitRequest: () => {},
26
+ onLaunchHarness: () => {},
26
27
  };
27
28
 
28
29
  const panelCallbacks = { onDividerGrab: () => {}, onToggle: () => {} };
@@ -14,6 +14,7 @@ const callbacks = {
14
14
  onSelectCuration: () => {},
15
15
  onUndoCuration: () => {},
16
16
  onSubmitRequest: () => {},
17
+ onLaunchHarness: () => {},
17
18
  };
18
19
 
19
20
  /** The rail expects a height-constrained parent, like the app's main row. */
@@ -17,6 +17,7 @@ import { Button } from "./primitives/Button";
17
17
  import { Toolbar } from "./primitives/Toolbar";
18
18
  import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
19
19
  import { ConfirmCard, type ConfirmCardProps } from "./ConfirmCard";
20
+ import { AgentLauncher } from "./agent-launcher";
20
21
  import { Card } from "./primitives/Card";
21
22
  import { truncateToSingleLine } from "./truncate-text";
22
23
  import { resolveDisplayName } from "../attribution";
@@ -60,6 +61,8 @@ export interface ReviewRailProps {
60
61
  /** Restore a curation item (the selected card's undo button, same as the u key). */
61
62
  onUndoCuration: (id: string) => void;
62
63
  onSubmitRequest: () => void;
64
+ /** Launch a bring-your-own harness in the rail (Agent tab); seedText is the plan-context briefing. */
65
+ onLaunchHarness: (command: string, seedText?: string) => void;
63
66
  /** Rail column width; the app derives it from the persisted review layout. */
64
67
  width?: number;
65
68
  /**
@@ -93,6 +96,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
93
96
  onSelectCuration,
94
97
  onUndoCuration,
95
98
  onSubmitRequest,
99
+ onLaunchHarness,
96
100
  width = 34,
97
101
  onCollapse,
98
102
  theme,
@@ -197,7 +201,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
197
201
  {railTab === "agent" ? (
198
202
  <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 2 }}>
199
203
  <text> </text>
200
- <AgentTab session={session} theme={theme} />
204
+ <AgentLauncher session={session} onLaunchHarness={onLaunchHarness} theme={theme} />
201
205
  </box>
202
206
  ) : openAnnotations.length === 0 && curationItems.length === 0 ? (
203
207
  <box style={{ flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
@@ -310,15 +314,3 @@ function RemovalCard({
310
314
  </box>
311
315
  );
312
316
  }
313
-
314
- /** Agent tab: who submitted, where the session stands, which revision. */
315
- function AgentTab({ session, theme }: { session: ReviewSession; theme?: Theme }): React.ReactNode {
316
- const tokens = useComponentTheme(theme);
317
- return (
318
- <box style={{ flexDirection: "column", flexGrow: 1 }}>
319
- <text fg={tokens.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
320
- <text fg={tokens.textDim}>status: {session.status}</text>
321
- <text fg={tokens.textDim}>revision {session.revisions.length}</text>
322
- </box>
323
- );
324
- }
@@ -19,6 +19,8 @@ export interface SettingsCategory {
19
19
  name: string;
20
20
  description: string;
21
21
  rows: SettingsRowDescriptor[];
22
+ /** A bespoke body (e.g. the quick-actions editor) rendered instead of typed rows. */
23
+ customBody?: React.ReactNode;
22
24
  }
23
25
 
24
26
  export type SettingsValues = Record<string, string | boolean>;
@@ -86,6 +88,7 @@ export function SettingsDialog({
86
88
  <text fg={tokens.text}>{category.name}</text>
87
89
  <text fg={tokens.textDim}>{category.description}</text>
88
90
  <box style={{ height: 1 }} />
91
+ {category.customBody}
89
92
  {category.rows.map((row, rowIndex) => {
90
93
  const isActive = activeZone === "body" && rowIndex === activeRowIndex;
91
94
  const value = values[row.key];
@@ -937,25 +937,25 @@ exports[`stories catalog ReviewRail/AgentTab renders, snapshots, and carries its
937
937
  │ Review Agent │
938
938
  ╰───────────────────────────────╯
939
939
 
940
- agent/worker-3
941
- status: pending
942
- revision 1
943
-
944
-
945
-
946
-
947
-
948
-
949
-
950
-
951
-
952
-
953
-
954
-
955
-
956
-
940
+ Ask an agent about this plan
941
+ ╭─────────────────────────────╮
942
+ │ ▛████▜ claude code │
943
+ │ ▜██████▛ launch in rail ▸ │
944
+ │ ▝▝ ▝▝ │
945
+ ╰─────────────────────────────╯
957
946
 
947
+ ╭─────────────────────────────╮
948
+ │ ███ pi │
949
+ │ █ █ launch in rail ▸ │
950
+ │ ██ █ │
951
+ │ █ █ │
952
+ ╰─────────────────────────────╯
958
953
 
954
+ ╭─────────────────────────────╮
955
+ │ ┌───┐ codex │
956
+ │ │>_ │ launch in rail ▸ │
957
+ │ └───┘ │
958
+ ╰─────────────────────────────╯
959
959
 
960
960
  "
961
961
  `;
@@ -1389,6 +1389,34 @@ exports[`stories catalog WalkWizard/WithAgentNote renders, snapshots, and carrie
1389
1389
 
1390
1390
 
1391
1391
 
1392
+ "
1393
+ `;
1394
+
1395
+ exports[`stories catalog AgentLauncher/Launcher renders, snapshots, and carries its colors: AgentLauncher/Launcher 1`] = `
1396
+ " Ask an agent about this plan
1397
+
1398
+ ╭────────────────────────────────────╮
1399
+ │ ▛████▜ claude code │
1400
+ │ ▜██████▛ launch in rail ▸ │
1401
+ │ ▝▝ ▝▝ │
1402
+ ╰────────────────────────────────────╯
1403
+
1404
+ ╭────────────────────────────────────╮
1405
+ │ ███ pi │
1406
+ │ █ █ launch in rail ▸ │
1407
+ │ ██ █ │
1408
+ │ █ █ │
1409
+ ╰────────────────────────────────────╯
1410
+
1411
+ ╭────────────────────────────────────╮
1412
+ │ ┌───┐ codex │
1413
+ │ │>_ │ launch in rail ▸ │
1414
+ │ └───┘ │
1415
+ ╰────────────────────────────────────╯
1416
+
1417
+ plan context: seed the plan ▸
1418
+
1419
+ agent/worker-3 · pending · rev 1
1392
1420
  "
1393
1421
  `;
1394
1422
 
@@ -1661,6 +1689,58 @@ exports[`stories catalog primitives/Toolbar/ActionRow renders, snapshots, and ca
1661
1689
 
1662
1690
 
1663
1691
 
1692
+ "
1693
+ `;
1694
+
1695
+ exports[`stories catalog QuickActionsEditor/Collapsed renders, snapshots, and carries its colors: QuickActionsEditor/Collapsed 1`] = `
1696
+ " click a prompt to edit its system reset to
1697
+ prompt defaults ▸
1698
+
1699
+ ▸ Zoom out, research in depth
1700
+ Research the broader problem, prior art, an…
1701
+ ▸ Restate simplified
1702
+ Restate this as the simplest thing that wor…
1703
+ ▸ Out of scope
1704
+ This is out of scope for the task, so captu…
1705
+ ▸ Let's chat about this
1706
+ Do not implement yet, and surface the open …
1707
+ ▸ Prototype this
1708
+ Build a throwaway prototype to answer the q…
1709
+ ▸ Ensure 0 regressions
1710
+ Characterize the current behavior with test…
1711
+ ▸ Consider existing repo patterns
1712
+ Follow the nearest existing pattern in this…
1713
+ + Add action
1714
+
1715
+
1716
+
1717
+
1718
+ "
1719
+ `;
1720
+
1721
+ exports[`stories catalog QuickActionsEditor/RowExpanded renders, snapshots, and carries its colors: QuickActionsEditor/RowExpanded 1`] = `
1722
+ " click a prompt to edit its system reset to
1723
+ prompt defaults ▸
1724
+
1725
+ ▸ Zoom out, research in depth
1726
+ Research the broader problem, prior art, an…
1727
+ ▾ Restate simplified
1728
+ > Restate this as the simplest thing that works an
1729
+ ▸ Out of scope
1730
+ This is out of scope for the task, so captu…
1731
+ ▸ Let's chat about this
1732
+ Do not implement yet, and surface the open …
1733
+ ▸ Prototype this
1734
+ Build a throwaway prototype to answer the q…
1735
+ ▸ Ensure 0 regressions
1736
+ Characterize the current behavior with test…
1737
+ ▸ Consider existing repo patterns
1738
+ Follow the nearest existing pattern in this…
1739
+ + Add action
1740
+
1741
+
1742
+
1743
+
1664
1744
  "
1665
1745
  `;
1666
1746
 
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { AgentLauncher } from "./agent-launcher";
5
+ import { fixturePlanSession } from "./story-fixtures";
6
+
7
+ export const meta: StoryMeta = { title: "AgentLauncher" };
8
+
9
+ /** The launcher expects the rail's narrow, height-constrained column. */
10
+ function RailFrame({ children }: { children: React.ReactNode }): React.ReactNode {
11
+ return (
12
+ <box style={{ width: 40, height: 24, paddingLeft: 2, backgroundColor: DARK.panel }}>
13
+ {children}
14
+ </box>
15
+ );
16
+ }
17
+
18
+ export const Launcher: Story = {
19
+ render: () => (
20
+ <RailFrame>
21
+ <AgentLauncher session={fixturePlanSession()} onLaunchHarness={() => {}} theme={DARK} />
22
+ </RailFrame>
23
+ ),
24
+ expectedColors: [DARK.accent, "#cc785c"],
25
+ size: { width: 40, height: 24 },
26
+ };
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { HARNESS_LAUNCHERS, planHandoffBriefing } from "./agent-launcher";
3
+
4
+ describe("planHandoffBriefing", () => {
5
+ test("references the session by id and directs the agent to read then annotate, not rewrite", () => {
6
+ // Act
7
+ const briefing = planHandoffBriefing("ses_42");
8
+
9
+ // Assert
10
+ expect(briefing).toContain("cueloop session get ses_42");
11
+ expect(briefing).toContain("cueloop session annotate ses_42");
12
+ expect(briefing).toContain("Do not rewrite the plan");
13
+ });
14
+ });
15
+
16
+ describe("HARNESS_LAUNCHERS", () => {
17
+ test("carries the three branded harnesses with their launch commands", () => {
18
+ // Assert
19
+ expect(HARNESS_LAUNCHERS.map((harness) => harness.command)).toEqual(["cc", "pi", "codex"]);
20
+ });
21
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The Agent rail launcher: bring-your-own review harness. Branded claude code /
3
+ * pi / codex cards that, on click, run that harness in a herdr split beside the
4
+ * review (see launchHarnessInSplit) so a reviewer can ask an agent about the plan
5
+ * without leaving the tab. A plan-context toggle seeds a briefing into the split.
6
+ */
7
+
8
+ import React, { useState } from "react";
9
+ import type { ReviewSession } from "@cueloop/schema";
10
+ import type { Theme } from "../theme";
11
+ import { useComponentTheme } from "./theme-context";
12
+ import { FRAME_BORDER_STYLE } from "./primitives/frame";
13
+
14
+ /** One launchable harness: its rail command and the real logo, rendered as colored rows. */
15
+ export interface HarnessLauncher {
16
+ id: string;
17
+ name: string;
18
+ /** The shell command that starts the harness, run in the split. */
19
+ command: string;
20
+ /** Brand color for the mark. */
21
+ color: string;
22
+ /** Multi-row mark rendered from the real logo. */
23
+ logo: string[];
24
+ }
25
+
26
+ /** Claude Code's brand coral - not a theme token, the mark keeps its own color. */
27
+ const CLAUDE_CORAL = "#CC785C";
28
+
29
+ /** The bring-your-own harnesses, with their real marks (finalized in the prototype). */
30
+ export const HARNESS_LAUNCHERS: HarnessLauncher[] = [
31
+ {
32
+ id: "claude",
33
+ name: "claude code",
34
+ command: "cc",
35
+ color: CLAUDE_CORAL,
36
+ logo: [" ▛████▜", "▜██████▛", " ▝▝ ▝▝"],
37
+ },
38
+ { id: "pi", name: "pi", command: "pi", color: "#e4e6ec", logo: ["███ ", "█ █ ", "██ █", "█ █"] },
39
+ {
40
+ id: "codex",
41
+ name: "codex",
42
+ command: "codex",
43
+ color: "#e4e6ec",
44
+ logo: ["┌───┐", "│>_ │", "└───┘"],
45
+ },
46
+ ];
47
+
48
+ /** The briefing typed into a launched harness when the plan-context toggle is on. */
49
+ export function planHandoffBriefing(sessionId: string): string {
50
+ return `Review this cueloop plan: read it with 'cueloop session get ${sessionId}', then comment with 'cueloop session annotate ${sessionId} --author me --quote "<exact span>" --body "<comment>"'. Do not rewrite the plan.`;
51
+ }
52
+
53
+ function LogoMark({ harness }: { harness: HarnessLauncher }): React.ReactNode {
54
+ return (
55
+ <box style={{ width: 9, flexShrink: 0, flexDirection: "column", alignItems: "flex-start" }}>
56
+ {harness.logo.map((row) => (
57
+ <text key={row} fg={harness.color}>
58
+ {row}
59
+ </text>
60
+ ))}
61
+ </box>
62
+ );
63
+ }
64
+
65
+ export interface AgentLauncherProps {
66
+ session: ReviewSession;
67
+ /** Launch a harness in the rail; seedText is the plan-context briefing when the toggle is on. */
68
+ onLaunchHarness: (command: string, seedText?: string) => void;
69
+ theme?: Theme;
70
+ }
71
+
72
+ /**
73
+ * The Agent tab body: branded launcher cards plus a plan-context toggle. Replaces
74
+ * the old dead agent/status/revision placeholder (now a dim footer line).
75
+ */
76
+ export function AgentLauncher({
77
+ session,
78
+ onLaunchHarness,
79
+ theme,
80
+ }: AgentLauncherProps): React.ReactNode {
81
+ const tokens = useComponentTheme(theme);
82
+ const [seedContext, setSeedContext] = useState(true);
83
+ const launch = (harness: HarnessLauncher): void =>
84
+ onLaunchHarness(harness.command, seedContext ? planHandoffBriefing(session.id) : undefined);
85
+ return (
86
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
87
+ <text fg={tokens.text}>Ask an agent about this plan</text>
88
+ <box style={{ height: 1 }} />
89
+ {HARNESS_LAUNCHERS.map((harness) => (
90
+ <box
91
+ key={harness.id}
92
+ style={{
93
+ flexDirection: "row",
94
+ height: harness.logo.length + 2,
95
+ border: true,
96
+ borderStyle: FRAME_BORDER_STYLE,
97
+ backgroundColor: "transparent",
98
+ borderColor: tokens.border,
99
+ marginBottom: 1,
100
+ paddingLeft: 1,
101
+ paddingRight: 1,
102
+ }}
103
+ onMouseUp={() => launch(harness)}
104
+ >
105
+ <LogoMark harness={harness} />
106
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1 }}>
107
+ <text fg={tokens.textMuted}>{harness.name}</text>
108
+ <text fg={tokens.textDim}>launch in rail ▸</text>
109
+ </box>
110
+ </box>
111
+ ))}
112
+ <box style={{ flexDirection: "row" }} onMouseUp={() => setSeedContext((on) => !on)}>
113
+ <text fg={tokens.textDim}>plan context: </text>
114
+ <text fg={tokens.accent}>{seedContext ? "seed the plan ▸" : "none ▸"}</text>
115
+ </box>
116
+ <box style={{ flexGrow: 1 }} />
117
+ <text fg={tokens.textDim}>
118
+ {session.artifact.meta.agent ?? "unknown"} · {session.status} · rev{" "}
119
+ {session.revisions.length}
120
+ </text>
121
+ </box>
122
+ );
123
+ }
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import { DEFAULT_QUICK_ACTIONS } from "../config";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { QuickActionsEditor } from "./quick-actions-editor";
6
+
7
+ export const meta: StoryMeta = { title: "QuickActionsEditor" };
8
+
9
+ /** The editor renders inside the settings body column. */
10
+ function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
11
+ return (
12
+ <box style={{ width: 54, height: 22, paddingLeft: 2, backgroundColor: DARK.elevated }}>
13
+ {children}
14
+ </box>
15
+ );
16
+ }
17
+
18
+ const noop = () => {};
19
+
20
+ export const Collapsed: Story = {
21
+ render: () => (
22
+ <BodyFrame>
23
+ <QuickActionsEditor
24
+ actions={DEFAULT_QUICK_ACTIONS}
25
+ selectedIndex={0}
26
+ expandedIndex={null}
27
+ onToggleExpand={noop}
28
+ onEditMetadata={noop}
29
+ onReset={noop}
30
+ onAdd={noop}
31
+ theme={DARK}
32
+ />
33
+ </BodyFrame>
34
+ ),
35
+ size: { width: 54, height: 22 },
36
+ };
37
+
38
+ export const RowExpanded: Story = {
39
+ render: () => (
40
+ <BodyFrame>
41
+ <QuickActionsEditor
42
+ actions={DEFAULT_QUICK_ACTIONS}
43
+ selectedIndex={1}
44
+ expandedIndex={1}
45
+ onToggleExpand={noop}
46
+ onEditMetadata={noop}
47
+ onReset={noop}
48
+ onAdd={noop}
49
+ theme={DARK}
50
+ />
51
+ </BodyFrame>
52
+ ),
53
+ size: { width: 54, height: 22 },
54
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The Settings "Actions" body: the quick-action vocabulary editor. Each row is a
3
+ * clickable prompt; the selected-and-expanded row reveals a focused input for the
4
+ * per-action system prompt (the extra guidance appended when the action is used).
5
+ * A reset-to-defaults control and an add-action row bracket the list. Controlled:
6
+ * the app owns the actions, the selection, and which row is expanded.
7
+ */
8
+
9
+ import React, { useRef } from "react";
10
+ import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
+ import type { QuickAction } from "../config";
12
+ import type { Theme } from "../theme";
13
+ import { useComponentTheme } from "./theme-context";
14
+
15
+ // ⏎ never inserts a newline: a system prompt is one line, esc closes the editor.
16
+ const ACTION_INPUT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
17
+
18
+ export interface QuickActionsEditorProps {
19
+ actions: QuickAction[];
20
+ /** The highlighted row; equals the settings body row index. */
21
+ selectedIndex: number;
22
+ /** The row whose system-prompt input is open and focused, or null. */
23
+ expandedIndex: number | null;
24
+ /** Select and toggle a row's system-prompt input open/closed. */
25
+ onToggleExpand: (index: number) => void;
26
+ onEditMetadata: (index: number, metadata: string) => void;
27
+ onReset: () => void;
28
+ onAdd: () => void;
29
+ theme?: Theme;
30
+ }
31
+
32
+ export function QuickActionsEditor({
33
+ actions,
34
+ selectedIndex,
35
+ expandedIndex,
36
+ onToggleExpand,
37
+ onEditMetadata,
38
+ onReset,
39
+ onAdd,
40
+ theme,
41
+ }: QuickActionsEditorProps): React.ReactNode {
42
+ const tokens = useComponentTheme(theme);
43
+ return (
44
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
45
+ <box style={{ flexDirection: "row" }}>
46
+ <box style={{ flexGrow: 1 }}>
47
+ <text fg={tokens.textDim}>click a prompt to edit its system prompt</text>
48
+ </box>
49
+ <box onMouseUp={onReset}>
50
+ <text fg={tokens.textDim}>reset to defaults ▸</text>
51
+ </box>
52
+ </box>
53
+ <box style={{ height: 1 }} />
54
+ {actions.map((action, index) => (
55
+ <ActionRow
56
+ key={index}
57
+ action={action}
58
+ isSelected={index === selectedIndex}
59
+ isExpanded={index === expandedIndex}
60
+ onToggleExpand={() => onToggleExpand(index)}
61
+ onEditMetadata={(metadata) => onEditMetadata(index, metadata)}
62
+ theme={theme}
63
+ />
64
+ ))}
65
+ <box
66
+ style={{ backgroundColor: selectedIndex === actions.length ? tokens.border : undefined }}
67
+ onMouseUp={onAdd}
68
+ >
69
+ <text fg={tokens.textMuted}>+ Add action</text>
70
+ </box>
71
+ </box>
72
+ );
73
+ }
74
+
75
+ function ActionRow({
76
+ action,
77
+ isSelected,
78
+ isExpanded,
79
+ onToggleExpand,
80
+ onEditMetadata,
81
+ theme,
82
+ }: {
83
+ action: QuickAction;
84
+ isSelected: boolean;
85
+ isExpanded: boolean;
86
+ onToggleExpand: () => void;
87
+ onEditMetadata: (metadata: string) => void;
88
+ theme?: Theme;
89
+ }): React.ReactNode {
90
+ const tokens = useComponentTheme(theme);
91
+ const inputRef = useRef<TextareaRenderable | null>(null);
92
+ return (
93
+ <box style={{ flexDirection: "column" }}>
94
+ <box
95
+ style={{ backgroundColor: isSelected ? tokens.border : undefined }}
96
+ onMouseUp={onToggleExpand}
97
+ >
98
+ <text fg={isSelected ? tokens.text : tokens.textMuted}>
99
+ {`${isExpanded ? "▾ " : "▸ "}${action.prompt}`}
100
+ </text>
101
+ </box>
102
+ {isExpanded ? (
103
+ <box style={{ flexDirection: "row", paddingLeft: 2 }}>
104
+ <box style={{ width: 2, flexShrink: 0 }}>
105
+ <text fg={tokens.textDim}>{">"}</text>
106
+ </box>
107
+ <textarea
108
+ ref={inputRef}
109
+ focused
110
+ initialValue={action.metadata ?? ""}
111
+ placeholder="extra system prompt appended to this comment (optional)"
112
+ keyBindings={ACTION_INPUT_KEY_BINDINGS}
113
+ onContentChange={() => onEditMetadata(inputRef.current?.plainText ?? "")}
114
+ style={{
115
+ height: 1,
116
+ flexGrow: 1,
117
+ backgroundColor: tokens.elevated,
118
+ focusedBackgroundColor: tokens.elevated,
119
+ textColor: tokens.text,
120
+ focusedTextColor: tokens.text,
121
+ }}
122
+ />
123
+ </box>
124
+ ) : action.metadata ? (
125
+ <box style={{ paddingLeft: 2 }}>
126
+ <text fg={tokens.textDim}>{truncateMetadata(action.metadata)}</text>
127
+ </box>
128
+ ) : null}
129
+ </box>
130
+ );
131
+ }
132
+
133
+ /** A one-line preview of the system prompt under a collapsed row. */
134
+ function truncateMetadata(metadata: string): string {
135
+ return metadata.length > 44 ? `${metadata.slice(0, 43)}…` : metadata;
136
+ }
@@ -9,6 +9,7 @@ import {
9
9
  loadConfig,
10
10
  persistAuthorName,
11
11
  persistReviewState,
12
+ persistActions,
12
13
  persistReviewWidth,
13
14
  persistTheme,
14
15
  quickActionBody,
@@ -420,6 +421,33 @@ describe("quick actions ([[actions]])", () => {
420
421
  });
421
422
  });
422
423
 
424
+ describe("persistActions", () => {
425
+ test("round-trips the action set through [[actions]] and replaces prior ones", () => {
426
+ // Arrange
427
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-persist-actions-"));
428
+ const path = join(dir, "config.toml");
429
+ writeFileSync(path, `[[actions]]\nprompt = "old one"\n\n[ui]\nauto_close = 3\n`);
430
+
431
+ try {
432
+ // Act
433
+ persistActions(
434
+ [{ prompt: "Zoom out" }, { prompt: "Prototype", metadata: "skip tests and polish" }],
435
+ path,
436
+ );
437
+ const config = loadConfig({ userConfigPath: path });
438
+
439
+ // Assert - the old action is gone, the new set is read back, and [ui] survives
440
+ expect(config.actions).toEqual([
441
+ { prompt: "Zoom out" },
442
+ { prompt: "Prototype", metadata: "skip tests and polish" },
443
+ ]);
444
+ expect(config.ui.autoClose).toBe(3);
445
+ } finally {
446
+ rmSync(dir, { recursive: true, force: true });
447
+ }
448
+ });
449
+ });
450
+
423
451
  describe("quickActionBody", () => {
424
452
  test("appends the system prompt below the prompt when set", () => {
425
453
  // Assert
package/src/config.ts CHANGED
@@ -318,6 +318,33 @@ function persistUiSetting(key: string, rendered: string, userConfigPath?: string
318
318
  writeFileSync(path, text);
319
319
  }
320
320
 
321
+ /** A double-quoted TOML basic string with the quote-breaking characters escaped. */
322
+ function tomlString(value: string): string {
323
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
324
+ }
325
+
326
+ /**
327
+ * Persist the whole quick-action set as `[[actions]]` tables, replacing any
328
+ * existing ones. The set is the user's entire vocabulary (not merged), so the
329
+ * old blocks are stripped and the new ones appended. An empty set clears them.
330
+ */
331
+ export function persistActions(actions: QuickAction[], userConfigPath?: string): void {
332
+ const path = userConfigPathFrom(userConfigPath);
333
+ let text = existsSync(path) ? readFileSync(path, "utf8") : "";
334
+ // strip every existing [[actions]] block (header through its key lines)
335
+ text = text.replace(/^\[\[actions\]\][^[]*/gm, "").trimEnd();
336
+ const blocks = actions
337
+ .map((action) => {
338
+ const lines = [`[[actions]]`, `prompt = ${tomlString(action.prompt)}`];
339
+ if (action.metadata) lines.push(`metadata = ${tomlString(action.metadata)}`);
340
+ return lines.join("\n");
341
+ })
342
+ .join("\n\n");
343
+ text = blocks ? `${text ? `${text}\n\n` : ""}${blocks}\n` : `${text}\n`;
344
+ mkdirSync(dirname(path), { recursive: true });
345
+ writeFileSync(path, text);
346
+ }
347
+
321
348
  /** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
322
349
  export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
323
350
  persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);