@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.
@@ -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 "/" palette and inline completion hint below it,
4
- * the edge-segmented discussion card, and one comment row. The plan thread view
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, { useEffect, useRef, useState } from "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 type { InlineSlash, SlashItem } from "../slash-palette";
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
- // once per mount (the composer is keyed by its seed): later re-renders
61
- // must NOT reset the caret, or input lands before a just-typed newline
62
- useEffect(() => {
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 palette list for a leading "/", else the inline tab-hint, else nothing. */
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: "row", marginLeft: 3 }}>
171
- <text>
172
- <span fg={tokens.textDim}>{"⇥ "}</span>
173
- <span fg={tokens.accent}>{`/${inline.suggestion.name}`}</span>
174
- <span fg={tokens.textDim}>{` ${inline.suggestion.description}`}</span>
175
- </text>
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 shown only under the always-visible
2
- // thread column. It carries the repo/branch context on the left and a written,
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
- /** Inline skill completion in the composer (#25): the trailing "/word" token and its match. */
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 { inlineSlashToken, resolveInlineSuggestion, ThreadView } from "./ThreadView";
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
- async function mountComposer() {
23
- const display = buildDisplay(PLAN, undefined);
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
- await settle(setup);
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
- describe("inlineSlashToken", () => {
48
- test("finds the trailing slash token when text precedes it", () => {
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
- <ThreadView
97
- session={fixturePlanSession({
98
- artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
99
- })}
100
- display={display}
101
- marks={marksByDisplay([], display)}
102
- quickActions={SKILLS}
103
- observer={false}
104
- onAnnotate={() => {}}
105
- onReply={() => {}}
106
- onUpdateAnnotation={() => {}}
107
- onExit={() => {}}
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: 72, height: 20 },
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("inline completion in the composer", () => {
136
- test("typing an inline slash shows the tab-hint, and tab completes to the full name", async () => {
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
- // Assert - the hint offers the closest skill, marked with the tab glyph
143
- const withHint = setup.captureCharFrame();
94
+ expect(completed).toContain("run /typescript-magician");
144
95
 
145
- expect(withHint).toContain("⇥");
146
- expect(withHint).toContain("/typescript-magician");
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
- // Assert - the hint is gone and the composer now carries the full skill name
153
- const completed = setup.captureCharFrame();
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(completed).not.toContain("⇥");
156
- expect(completed).toContain("typescript-magician");
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
- /** A printable character: single-width input that should reach a composer. */
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
- const printable =
117
- key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta && key.sequence >= " ";
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 printable ? key.sequence : null;
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 { inlineSlashToken, scoreMatch, slashFilter, type SlashItem } from "./slash-palette";
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("inlineSlashToken", () => {
50
- test("finds a trailing /word only when text precedes it", () => {
51
- expect(inlineSlashToken("use /vitest-patt")).toBe("/vitest-patt");
52
- expect(inlineSlashToken("/vitest-patt")).toBeNull();
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
  });
@@ -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
- * A skill invoked mid-sentence: the trailing "/word" token when text already
69
- * precedes it (a draft that starts with "/" is the palette, not an inline
70
- * completion). Newline-safe, since the token may sit at the start of a new line.
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 inlineSlashToken(text: string): string | null {
73
- if (text.startsWith("/")) return null;
74
- const match = /(?:^|\s)(\/[a-zA-Z0-9-]*)$/.exec(text);
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
- export interface InlineSlash {
80
- token: string;
81
- suggestion: SlashItem;
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
- /** The inline completion state: the trailing "/word" and its closest item, or null. */
85
- export function resolveInlineSuggestion(
86
- slashActive: boolean,
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
- items: SlashItem[],
89
- ): InlineSlash | null {
90
- if (slashActive) return null;
91
- const token = inlineSlashToken(text);
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
- if (token === null) return null;
94
- const suggestion = slashFilter(items, token.slice(1))[0];
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 suggestion ? { token, suggestion } : null;
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). */