@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 +5 -5
- package/src/App.plan-review.test.tsx +33 -6
- package/src/App.tsx +79 -1
- package/src/components/ReviewPanel.stories.tsx +1 -0
- package/src/components/ReviewRail.stories.tsx +1 -0
- package/src/components/ReviewRail.tsx +5 -13
- package/src/components/SettingsDialog.tsx +3 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +97 -17
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +123 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/config.test.ts +28 -0
- package/src/config.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
29
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
30
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
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
|
|
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, "
|
|
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
|
-
|
|
544
|
-
|
|
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) =>
|
|
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>
|
|
@@ -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
|
-
<
|
|
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
|
|
941
|
-
|
|
942
|
-
|
|
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
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -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);
|