@cueloop/client 0.1.0-alpha.70 → 0.1.0-alpha.72
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.tsx +245 -230
- package/src/App.workbench.test.tsx +34 -0
- package/src/components/AnnotationCards.stories.tsx +0 -1
- package/src/components/AnnotationCards.tsx +34 -30
- package/src/components/AppShell.tsx +5 -1
- package/src/components/AppShell.zoom.test.tsx +41 -0
- package/src/components/ThreadFooter.tsx +3 -3
- package/src/components/ThreadView.slash.test.tsx +86 -98
- package/src/components/ThreadView.tsx +0 -1
- package/src/components/syntax-highlight.ts +23 -0
- package/src/mark-runs.ts +5 -4
- package/src/skills.ts +7 -0
- package/src/slash-palette.test.ts +37 -5
- package/src/slash-palette.ts +33 -19
- package/src/use-annotation-surface.tsx +34 -25
- package/src/use-changes-workbench.ts +2 -0
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The inline comment pieces every annotated surface renders: the composer (a
|
|
3
|
-
* textarea in a card row), the "/"
|
|
4
|
-
*
|
|
3
|
+
* textarea in a card row), the "/" actions-and-skills palette below it, the
|
|
4
|
+
* edge-segmented discussion card, and one comment row. The plan thread view
|
|
5
5
|
* and the diff sheet both draw their comments with these, so a discussion looks
|
|
6
6
|
* and behaves the same on prose and on code.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import React, {
|
|
9
|
+
import React, { useContext, useLayoutEffect, useRef, useState } from "react";
|
|
10
10
|
import type { BoxRenderable, KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
11
11
|
import type { Annotation } from "@cueloop/schema";
|
|
12
12
|
import type { Theme } from "../theme";
|
|
13
13
|
import { lighten } from "../annotation-palette";
|
|
14
14
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
15
|
-
import
|
|
15
|
+
import { skillReferenceRanges, type SlashItem } from "../slash-palette";
|
|
16
|
+
import { PaletteNamesContext } from "../skills";
|
|
17
|
+
import { referenceStyleFor } from "./syntax-highlight";
|
|
16
18
|
|
|
17
19
|
/* -------------------------------------------------------------- composer */
|
|
18
20
|
|
|
@@ -52,18 +54,32 @@ export function Composer({
|
|
|
52
54
|
tokens: Theme;
|
|
53
55
|
onSave: (body: string) => void;
|
|
54
56
|
onReady: () => void;
|
|
55
|
-
onInput: (text: string) => void;
|
|
57
|
+
onInput: (text: string, caret: number) => void;
|
|
56
58
|
}): React.ReactNode {
|
|
57
59
|
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
58
60
|
const [rows, setRows] = useState(1);
|
|
61
|
+
// action and skill names whose "/name" references paint in the accent color
|
|
62
|
+
const referenceNames = useContext(PaletteNamesContext);
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
const paintReferences = (editor: TextareaRenderable): void => {
|
|
65
|
+
const { styleId } = referenceStyleFor(tokens);
|
|
66
|
+
|
|
67
|
+
editor.editBuffer.clearAllHighlights();
|
|
68
|
+
for (const range of skillReferenceRanges(editor.plainText, referenceNames)) {
|
|
69
|
+
editor.editBuffer.addHighlightByCharRange({ ...range, styleId });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// synchronously at commit, before the next tty key is read: signalling readiness in a
|
|
74
|
+
// post-paint effect lets a fast burst race the mount, splitting input between the seed
|
|
75
|
+
// buffer and this textarea out of order (#365)
|
|
76
|
+
useLayoutEffect(() => {
|
|
63
77
|
const editor = editorRef.current;
|
|
64
78
|
|
|
65
79
|
if (!editor) return;
|
|
66
80
|
editor.cursorOffset = seed.length;
|
|
81
|
+
editor.editBuffer.setSyntaxStyle(referenceStyleFor(tokens).style);
|
|
82
|
+
paintReferences(editor);
|
|
67
83
|
setRows(composeRowCount(editor.plainText, editor.width));
|
|
68
84
|
onReady();
|
|
69
85
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -83,8 +99,9 @@ export function Composer({
|
|
|
83
99
|
const editor = editorRef.current;
|
|
84
100
|
|
|
85
101
|
if (!editor) return;
|
|
102
|
+
paintReferences(editor);
|
|
86
103
|
setRows(composeRowCount(editor.plainText, editor.width));
|
|
87
|
-
onInput(editor.plainText);
|
|
104
|
+
onInput(editor.plainText, editor.cursorOffset);
|
|
88
105
|
}}
|
|
89
106
|
style={{
|
|
90
107
|
height: rows,
|
|
@@ -139,40 +156,27 @@ function SlashList({
|
|
|
139
156
|
);
|
|
140
157
|
}
|
|
141
158
|
|
|
142
|
-
/** Below the composer: the
|
|
159
|
+
/** Below the composer: the actions/skills list while the caret is on a "/word", else nothing. */
|
|
143
160
|
export function ComposerPalette({
|
|
144
161
|
slashActive,
|
|
145
162
|
slashItems,
|
|
146
163
|
slashIndex,
|
|
147
|
-
inline,
|
|
148
164
|
tokens,
|
|
149
165
|
}: {
|
|
150
166
|
slashActive: boolean;
|
|
151
167
|
slashItems: SlashItem[];
|
|
152
168
|
slashIndex: number;
|
|
153
|
-
inline: InlineSlash | null;
|
|
154
169
|
tokens: Theme;
|
|
155
170
|
}): React.ReactNode {
|
|
156
|
-
if (slashActive)
|
|
157
|
-
return (
|
|
158
|
-
<box style={{ flexDirection: "column", marginLeft: 3 }}>
|
|
159
|
-
<SlashList
|
|
160
|
-
items={slashItems}
|
|
161
|
-
selected={Math.min(slashIndex, Math.max(0, slashItems.length - 1))}
|
|
162
|
-
tokens={tokens}
|
|
163
|
-
/>
|
|
164
|
-
</box>
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
if (inline === null) return null;
|
|
171
|
+
if (!slashActive) return null;
|
|
168
172
|
|
|
169
173
|
return (
|
|
170
|
-
<box style={{ flexDirection: "
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
<box style={{ flexDirection: "column", marginLeft: 3 }}>
|
|
175
|
+
<SlashList
|
|
176
|
+
items={slashItems}
|
|
177
|
+
selected={Math.min(slashIndex, Math.max(0, slashItems.length - 1))}
|
|
178
|
+
tokens={tokens}
|
|
179
|
+
/>
|
|
176
180
|
</box>
|
|
177
181
|
);
|
|
178
182
|
}
|
|
@@ -37,6 +37,8 @@ export interface AppShellProps {
|
|
|
37
37
|
projectMode: ProjectPanelMode;
|
|
38
38
|
/** The Changes editor grid; rendered only when changesOpen. */
|
|
39
39
|
changesPanel?: React.ReactNode;
|
|
40
|
+
/** The thread footer, shown beneath the Changes grid while the Thread pane is zoomed away. */
|
|
41
|
+
changesFooter?: React.ReactNode;
|
|
40
42
|
projectPanel: React.ReactNode;
|
|
41
43
|
/** Hide the Thread pane so Changes fills the middle (zoom); the sidebars stay. */
|
|
42
44
|
zoomHideThread?: boolean;
|
|
@@ -90,6 +92,7 @@ export function AppShell({
|
|
|
90
92
|
onToggleRight,
|
|
91
93
|
projectMode,
|
|
92
94
|
changesPanel,
|
|
95
|
+
changesFooter,
|
|
93
96
|
projectPanel,
|
|
94
97
|
zoomHideThread,
|
|
95
98
|
footer,
|
|
@@ -227,7 +230,8 @@ export function AppShell({
|
|
|
227
230
|
borderColor: tokens.border,
|
|
228
231
|
}}
|
|
229
232
|
>
|
|
230
|
-
{changesPanel}
|
|
233
|
+
<box style={{ flexGrow: 1, minHeight: 0 }}>{changesPanel}</box>
|
|
234
|
+
{zoomHideThread ? changesFooter : null}
|
|
231
235
|
</box>
|
|
232
236
|
) : null}
|
|
233
237
|
{projectOpen ? (
|
|
@@ -80,6 +80,47 @@ describe("zoom icon in the shell", () => {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
test("the footer rides the Changes pane only while the Thread pane is zoomed away", async () => {
|
|
84
|
+
async function footerVisible(zoomed: boolean): Promise<boolean> {
|
|
85
|
+
const setup = await testRender(
|
|
86
|
+
<AppShell
|
|
87
|
+
sidebarOpen={false}
|
|
88
|
+
onToggleSidebar={() => {}}
|
|
89
|
+
onOpenMenu={() => {}}
|
|
90
|
+
threadsPanel={<text>threads</text>}
|
|
91
|
+
threadTitle="thread"
|
|
92
|
+
threadPanel={<text>body</text>}
|
|
93
|
+
changesOpen
|
|
94
|
+
projectOpen
|
|
95
|
+
onToggleChanges={() => {}}
|
|
96
|
+
onToggleProject={() => {}}
|
|
97
|
+
onToggleRight={() => {}}
|
|
98
|
+
projectMode="changes"
|
|
99
|
+
zoomHideThread={zoomed}
|
|
100
|
+
projectPanel={<text>project</text>}
|
|
101
|
+
changesFooter={<text>send message</text>}
|
|
102
|
+
changesPanel={<text>diff</text>}
|
|
103
|
+
/>,
|
|
104
|
+
{ width: 160, height: 8 },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
allowEventLoopUpdates();
|
|
108
|
+
await setup.waitForVisualIdle();
|
|
109
|
+
const shown = setup.captureCharFrame().includes("send message");
|
|
110
|
+
|
|
111
|
+
setup.renderer.destroy();
|
|
112
|
+
|
|
113
|
+
return shown;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Act
|
|
117
|
+
const [zoomed, unzoomed] = await Promise.all([footerVisible(true), footerVisible(false)]);
|
|
118
|
+
|
|
119
|
+
// Assert - the Thread pane carries the footer normally, so it must not double up in Changes
|
|
120
|
+
expect(zoomed).toBe(true);
|
|
121
|
+
expect(unzoomed).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
83
124
|
test("the icon clears the Project border by its padding in both states", async () => {
|
|
84
125
|
// Act
|
|
85
126
|
const setupColumns = await Promise.all([
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The thread's bottom band: a header-like bar
|
|
2
|
-
// thread column. It carries the repo/branch
|
|
3
|
-
// clickable "Send message" control on the right.
|
|
1
|
+
// The thread's bottom band: a header-like bar under the thread column, or the
|
|
2
|
+
// Changes column when a zoom drops the thread column. It carries the repo/branch
|
|
3
|
+
// context on the left and a written, clickable "Send message" control on the right.
|
|
4
4
|
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { DARK, type Theme } from "../theme";
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** The "/" palette in the composer: it reopens for each "/word" the caret writes, so skills chain. */
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from "bun:test";
|
|
4
4
|
import React from "react";
|
|
5
|
+
import type { RGBA } from "@opentui/core";
|
|
5
6
|
import { testRender } from "@opentui/react/test-utils";
|
|
6
7
|
import type { QuickAction } from "../config";
|
|
7
8
|
import { settle, typeText } from "../test-support";
|
|
9
|
+
import { DARK } from "../theme";
|
|
8
10
|
import { buildDisplay, marksByDisplay } from "../view-plan";
|
|
9
11
|
import { fixturePlanSession } from "./story-fixtures";
|
|
10
|
-
import {
|
|
11
|
-
import { slashItemsFrom, type SlashItem } from "../slash-palette";
|
|
12
|
-
import { SlashSkillsContext } from "../skills";
|
|
12
|
+
import { ThreadView } from "./ThreadView";
|
|
13
|
+
import { mergeSlashItems, slashItemsFrom, type SlashItem } from "../slash-palette";
|
|
14
|
+
import { PaletteNamesContext, SlashSkillsContext } from "../skills";
|
|
13
15
|
|
|
14
16
|
const SKILLS: QuickAction[] = [
|
|
15
17
|
{ prompt: "typescript magician" },
|
|
@@ -19,95 +21,35 @@ const SKILLS: QuickAction[] = [
|
|
|
19
21
|
|
|
20
22
|
const PLAN = "# Plan\n\nRefine the store before the rewrite lands.\n";
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const setup = await testRender(
|
|
25
|
-
<ThreadView
|
|
26
|
-
session={fixturePlanSession({
|
|
27
|
-
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
28
|
-
})}
|
|
29
|
-
display={display}
|
|
30
|
-
marks={marksByDisplay([], display)}
|
|
31
|
-
quickActions={SKILLS}
|
|
32
|
-
observer={false}
|
|
33
|
-
onAnnotate={() => {}}
|
|
34
|
-
onReply={() => {}}
|
|
35
|
-
onUpdateAnnotation={() => {}}
|
|
36
|
-
onExit={() => {}}
|
|
37
|
-
/>,
|
|
38
|
-
{ width: 72, height: 20 },
|
|
39
|
-
);
|
|
24
|
+
function colorToHex(color: RGBA): string {
|
|
25
|
+
const [red, green, blue] = color.toInts();
|
|
40
26
|
|
|
41
|
-
|
|
42
|
-
await settle(setup);
|
|
43
|
-
|
|
44
|
-
return setup;
|
|
27
|
+
return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
45
28
|
}
|
|
46
29
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
expect(inlineSlashToken("please run /type")).toBe("/type");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("ignores a draft that is itself a leading slash (that is the palette)", () => {
|
|
53
|
-
expect(inlineSlashToken("/type")).toBeNull();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("is newline-safe: a token at the start of a later line still resolves", () => {
|
|
57
|
-
expect(inlineSlashToken("first line\n/impl")).toBe("/impl");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("returns null when the trailing word is not a slash token", () => {
|
|
61
|
-
expect(inlineSlashToken("just some prose")).toBeNull();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("a bare slash mid-sentence is a valid, empty-query token", () => {
|
|
65
|
-
expect(inlineSlashToken("run /")).toBe("/");
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("resolveInlineSuggestion", () => {
|
|
70
|
-
test("offers the fuzzy closest skill for the trailing token", () => {
|
|
71
|
-
const inline = resolveInlineSuggestion(false, "run /type", slashItemsFrom(SKILLS));
|
|
72
|
-
|
|
73
|
-
expect(inline?.token).toBe("/type");
|
|
74
|
-
expect(inline?.suggestion.name).toBe("typescript-magician");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("prefers a prefix match over a subsequence match", () => {
|
|
78
|
-
const inline = resolveInlineSuggestion(false, "run /impl", slashItemsFrom(SKILLS));
|
|
79
|
-
|
|
80
|
-
expect(inline?.suggestion.name).toBe("implement");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("stays silent while the palette owns a leading slash", () => {
|
|
84
|
-
expect(resolveInlineSuggestion(true, "/type", slashItemsFrom(SKILLS))).toBeNull();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("returns null when nothing matches the token", () => {
|
|
88
|
-
expect(resolveInlineSuggestion(false, "run /zzzz", slashItemsFrom(SKILLS))).toBeNull();
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
async function mountComposerWithSkills(skills: SlashItem[]) {
|
|
30
|
+
/** Render a plan composer, wiring the palette names into context exactly as the app does. */
|
|
31
|
+
async function mountComposerWithSkills(skills: SlashItem[] = [], width = 72) {
|
|
93
32
|
const display = buildDisplay(PLAN, undefined);
|
|
33
|
+
const names = new Set(mergeSlashItems(slashItemsFrom(SKILLS), skills).map((item) => item.name));
|
|
94
34
|
const setup = await testRender(
|
|
95
35
|
<SlashSkillsContext.Provider value={skills}>
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
36
|
+
<PaletteNamesContext.Provider value={names}>
|
|
37
|
+
<ThreadView
|
|
38
|
+
session={fixturePlanSession({
|
|
39
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
40
|
+
})}
|
|
41
|
+
display={display}
|
|
42
|
+
marks={marksByDisplay([], display)}
|
|
43
|
+
quickActions={SKILLS}
|
|
44
|
+
observer={false}
|
|
45
|
+
onAnnotate={() => {}}
|
|
46
|
+
onReply={() => {}}
|
|
47
|
+
onUpdateAnnotation={() => {}}
|
|
48
|
+
onExit={() => {}}
|
|
49
|
+
/>
|
|
50
|
+
</PaletteNamesContext.Provider>
|
|
109
51
|
</SlashSkillsContext.Provider>,
|
|
110
|
-
{ width
|
|
52
|
+
{ width, height: 20 },
|
|
111
53
|
);
|
|
112
54
|
|
|
113
55
|
await settle(setup);
|
|
@@ -116,6 +58,8 @@ async function mountComposerWithSkills(skills: SlashItem[]) {
|
|
|
116
58
|
return setup;
|
|
117
59
|
}
|
|
118
60
|
|
|
61
|
+
const mountComposer = () => mountComposerWithSkills();
|
|
62
|
+
|
|
119
63
|
describe("skills in the palette", () => {
|
|
120
64
|
test("a skill from context lists in the / palette and a pick inserts its /name", async () => {
|
|
121
65
|
const setup = await mountComposerWithSkills([
|
|
@@ -132,27 +76,71 @@ describe("skills in the palette", () => {
|
|
|
132
76
|
});
|
|
133
77
|
});
|
|
134
78
|
|
|
135
|
-
describe("
|
|
136
|
-
test("
|
|
137
|
-
// Arrange - open a composer and type text with a trailing slash token
|
|
79
|
+
describe("the palette reopens per token and chains skills", () => {
|
|
80
|
+
test("a mid-sentence / opens the list, tab completes it, and a second / opens the list again", async () => {
|
|
138
81
|
const setup = await mountComposer();
|
|
139
82
|
|
|
83
|
+
// a trailing "/type" opens the full list under the composer, closest skill first
|
|
140
84
|
await typeText(setup, "run /type");
|
|
85
|
+
const firstList = setup.captureCharFrame();
|
|
86
|
+
|
|
87
|
+
expect(firstList).toContain("/typescript-magician");
|
|
88
|
+
|
|
89
|
+
// tab completes only that token, leaving the prose before it
|
|
90
|
+
setup.mockInput.pressKey("TAB");
|
|
91
|
+
await settle(setup);
|
|
92
|
+
const completed = setup.captureCharFrame();
|
|
141
93
|
|
|
142
|
-
|
|
143
|
-
const withHint = setup.captureCharFrame();
|
|
94
|
+
expect(completed).toContain("run /typescript-magician");
|
|
144
95
|
|
|
145
|
-
|
|
146
|
-
|
|
96
|
+
// a second "/" reopens the palette so another skill chains onto the draft
|
|
97
|
+
await typeText(setup, "/impl");
|
|
98
|
+
const secondList = setup.captureCharFrame();
|
|
99
|
+
|
|
100
|
+
expect(secondList).toContain("/implement");
|
|
147
101
|
|
|
148
|
-
// Act - tab completes the token
|
|
149
102
|
setup.mockInput.pressKey("TAB");
|
|
150
103
|
await settle(setup);
|
|
104
|
+
expect(setup.captureCharFrame()).toContain("run /typescript-magician /implement");
|
|
105
|
+
});
|
|
151
106
|
|
|
152
|
-
|
|
153
|
-
const
|
|
107
|
+
test("several skills picked on enter, with prose between, all persist in the draft", async () => {
|
|
108
|
+
const setup = await mountComposerWithSkills([], 120);
|
|
109
|
+
|
|
110
|
+
// pick the first skill, keep typing prose, pick the next, and so on
|
|
111
|
+
await typeText(setup, "start /type");
|
|
112
|
+
setup.mockInput.pressKey("RETURN");
|
|
113
|
+
await settle(setup);
|
|
114
|
+
|
|
115
|
+
await typeText(setup, "then run /impl");
|
|
116
|
+
setup.mockInput.pressKey("RETURN");
|
|
117
|
+
await settle(setup);
|
|
118
|
+
|
|
119
|
+
await typeText(setup, "and finally /write");
|
|
120
|
+
setup.mockInput.pressKey("RETURN");
|
|
121
|
+
await settle(setup);
|
|
122
|
+
|
|
123
|
+
// every pick landed as its full "/name", and the prose between them survived
|
|
124
|
+
expect(setup.captureCharFrame()).toContain(
|
|
125
|
+
"start /typescript-magician then run /implement and finally /write-discoverable-code",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a completed reference paints in the accent color", async () => {
|
|
130
|
+
const setup = await mountComposer();
|
|
131
|
+
|
|
132
|
+
await typeText(setup, "please /type");
|
|
133
|
+
setup.mockInput.pressKey("RETURN");
|
|
134
|
+
await settle(setup);
|
|
135
|
+
|
|
136
|
+
const accentText = setup
|
|
137
|
+
.captureSpans()
|
|
138
|
+
.lines.flatMap((line) => line.spans.filter((span) => colorToHex(span.fg) === DARK.accent))
|
|
139
|
+
.map((span) => span.text)
|
|
140
|
+
.join("");
|
|
154
141
|
|
|
155
|
-
expect(
|
|
156
|
-
|
|
142
|
+
expect(accentText).toContain("/typescript-magician");
|
|
143
|
+
// the prose around the reference is not accented
|
|
144
|
+
expect(accentText).not.toContain("please");
|
|
157
145
|
});
|
|
158
146
|
});
|
|
@@ -28,7 +28,6 @@ import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
|
28
28
|
import { useComponentTheme } from "./theme-context";
|
|
29
29
|
|
|
30
30
|
export { lighten } from "../annotation-palette";
|
|
31
|
-
export { inlineSlashToken, resolveInlineSuggestion } from "../slash-palette";
|
|
32
31
|
|
|
33
32
|
/**
|
|
34
33
|
* How a block's rows are painted: heading weight, muted kinds, the list or
|
|
@@ -97,6 +97,29 @@ export function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
|
|
97
97
|
return style;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** A one-group syntax style plus the styleId that paints a "/skill" reference in the accent color. */
|
|
101
|
+
export interface ReferenceStyle {
|
|
102
|
+
style: SyntaxStyle;
|
|
103
|
+
styleId: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const referenceStyleCache = new WeakMap<Theme, ReferenceStyle>();
|
|
107
|
+
|
|
108
|
+
export function referenceStyleFor(theme: Theme): ReferenceStyle {
|
|
109
|
+
const cached = referenceStyleCache.get(theme);
|
|
110
|
+
|
|
111
|
+
if (cached) return cached;
|
|
112
|
+
const entry: ReferenceStyle = {
|
|
113
|
+
style: SyntaxStyle.fromStyles({ reference: { fg: theme.accent } }),
|
|
114
|
+
styleId: 0,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
entry.styleId = entry.style.getStyleId("reference") ?? 0;
|
|
118
|
+
referenceStyleCache.set(theme, entry);
|
|
119
|
+
|
|
120
|
+
return entry;
|
|
121
|
+
}
|
|
122
|
+
|
|
100
123
|
/** The foreground for a tree-sitter capture group; undefined when unstyled. */
|
|
101
124
|
export function colorForSyntaxGroup(group: string, theme: Theme): string | undefined {
|
|
102
125
|
const styles = syntaxGroupStyles(theme);
|
package/src/mark-runs.ts
CHANGED
|
@@ -111,10 +111,11 @@ export function lineMarkRanges(ranges: MarkRange[], line: VisualLine): MarkRange
|
|
|
111
111
|
.filter((range) => range.end > range.start);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/**
|
|
114
|
+
/** Printable input that should reach a composer: one key, or a whole pasted run in one event. */
|
|
115
115
|
export function printableSequence(key: KeyEvent): string | null {
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
if (!key.sequence || key.ctrl || key.meta) return null;
|
|
117
|
+
// reject any run carrying a control byte, so escape sequences and \r\t\b never seed a draft
|
|
118
|
+
for (const character of key.sequence) if (character < " ") return null;
|
|
118
119
|
|
|
119
|
-
return
|
|
120
|
+
return key.sequence.length > 0 ? key.sequence : null;
|
|
120
121
|
}
|
package/src/skills.ts
CHANGED
|
@@ -66,3 +66,10 @@ export function loadSkills(dir: string): SlashItem[] {
|
|
|
66
66
|
|
|
67
67
|
/** Skills reach the composer palette through context, so no surface needs a new prop to list them. */
|
|
68
68
|
export const SlashSkillsContext = createContext<SlashItem[]>([]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The action and skill names every composer paints as "/name" references. Held in
|
|
72
|
+
* context so each surface - the plan thread, the diff sheet, the prototype - reads
|
|
73
|
+
* the same palette without threading a prop.
|
|
74
|
+
*/
|
|
75
|
+
export const PaletteNamesContext = createContext<ReadonlySet<string>>(new Set());
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
activeSlashToken,
|
|
4
|
+
isStandaloneSlashQuery,
|
|
5
|
+
scoreMatch,
|
|
6
|
+
slashFilter,
|
|
7
|
+
type SlashItem,
|
|
8
|
+
} from "./slash-palette";
|
|
3
9
|
|
|
4
10
|
function item(name: string): SlashItem {
|
|
5
11
|
return { name, description: name, body: name };
|
|
@@ -46,9 +52,35 @@ describe("slashFilter", () => {
|
|
|
46
52
|
});
|
|
47
53
|
});
|
|
48
54
|
|
|
49
|
-
describe("
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
describe("activeSlashToken", () => {
|
|
56
|
+
const atEnd = (text: string) => activeSlashToken(text, text.length);
|
|
57
|
+
|
|
58
|
+
test("finds the caret's /word at the start or after a space, so each new / reopens the palette", () => {
|
|
59
|
+
expect(atEnd("/vitest-patt")).toBe("/vitest-patt");
|
|
60
|
+
expect(atEnd("use /vitest-patt")).toBe("/vitest-patt");
|
|
61
|
+
expect(atEnd("/zoom-out hello world /")).toBe("/");
|
|
62
|
+
// an underscore is a valid skill-name character, so the token spans it
|
|
63
|
+
expect(atEnd("run /my_skill")).toBe("/my_skill");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("closes on the space that ends the token, and ignores prose", () => {
|
|
67
|
+
expect(atEnd("/zoom-out ")).toBeNull();
|
|
68
|
+
expect(atEnd("just some prose")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("tracks the caret, not the string end: a slash mid-draft is the active token", () => {
|
|
72
|
+
// caret sits right after "/imp" in "/imp done", ignoring the trailing text
|
|
73
|
+
expect(activeSlashToken("/imp done", 4)).toBe("/imp");
|
|
74
|
+
// caret inside plain prose, even with a later slash token, has no active token
|
|
75
|
+
expect(activeSlashToken("plain text /later", 5)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("isStandaloneSlashQuery", () => {
|
|
80
|
+
test("true only when the whole draft is a bare /query, so prose ending in /name still saves", () => {
|
|
81
|
+
expect(isStandaloneSlashQuery("/lgtm")).toBe(true);
|
|
82
|
+
expect(isStandaloneSlashQuery(" /lgtm ")).toBe(true);
|
|
83
|
+
expect(isStandaloneSlashQuery("please inspect /tmp")).toBe(false);
|
|
84
|
+
expect(isStandaloneSlashQuery("just prose")).toBe(false);
|
|
53
85
|
});
|
|
54
86
|
});
|
package/src/slash-palette.ts
CHANGED
|
@@ -64,36 +64,50 @@ export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
|
|
|
64
64
|
return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// a skill or action name: the grammar loadSkills and slashItemsFrom can produce, so a "/name"
|
|
68
|
+
// token closes only on whitespace or another slash, never inside a valid underscore-or-hyphen name
|
|
69
|
+
const SLASH_TOKEN = /(?:^|\s)(\/[a-zA-Z0-9_-]*)$/;
|
|
70
|
+
|
|
67
71
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
72
|
+
* The "/word" token the caret is writing right now: the run from the last
|
|
73
|
+
* whitespace up to the caret, at the start of the draft or after it. Null once a
|
|
74
|
+
* space closes the token, so each new "/" reopens the palette and skills chain.
|
|
75
|
+
* Caret-aware, so editing an earlier "/name" targets it, not a trailing token.
|
|
71
76
|
*/
|
|
72
|
-
export function
|
|
73
|
-
|
|
74
|
-
const match =
|
|
77
|
+
export function activeSlashToken(text: string, caret: number): string | null {
|
|
78
|
+
const head = text.slice(0, Math.max(0, Math.min(caret, text.length)));
|
|
79
|
+
const match = SLASH_TOKEN.exec(head);
|
|
75
80
|
|
|
76
81
|
return match ? match[1]! : null;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
/** True while the whole draft is just a "/query" - the palette owns it, so a blur discards it. */
|
|
85
|
+
export function isStandaloneSlashQuery(text: string): boolean {
|
|
86
|
+
return /^\/\S*$/.test(text.trim());
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The character ranges of every completed "/name" in the draft that names a real
|
|
91
|
+
* action or skill, so a surface can paint the references. A half-typed "/na" is
|
|
92
|
+
* not a reference yet, so it stays unpainted until the palette completes it.
|
|
93
|
+
*/
|
|
94
|
+
export function skillReferenceRanges(
|
|
87
95
|
text: string,
|
|
88
|
-
|
|
89
|
-
):
|
|
90
|
-
|
|
91
|
-
const
|
|
96
|
+
names: ReadonlySet<string>,
|
|
97
|
+
): Array<{ start: number; end: number }> {
|
|
98
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
99
|
+
const pattern = /(?:^|\s)(\/[a-zA-Z0-9_-]+)/g;
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
|
|
102
|
+
const token = match[1]!;
|
|
103
|
+
|
|
104
|
+
if (!names.has(token.slice(1))) continue;
|
|
105
|
+
const start = match.index + match[0].length - token.length;
|
|
106
|
+
|
|
107
|
+
ranges.push({ start, end: start + token.length });
|
|
108
|
+
}
|
|
95
109
|
|
|
96
|
-
return
|
|
110
|
+
return ranges;
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
/** Quick actions and user skills in one palette; a quick action wins a name collision (it expands). */
|