@cueloop/client 0.1.0-alpha.71 → 0.1.0-alpha.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.71",
3
+ "version": "0.1.0-alpha.73",
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": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.71",
31
- "@cueloop/extension-api": "0.1.0-alpha.71",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.71",
33
- "@cueloop/schema": "0.1.0-alpha.71",
30
+ "@cueloop/daemon": "0.1.0-alpha.73",
31
+ "@cueloop/extension-api": "0.1.0-alpha.73",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.73",
33
+ "@cueloop/schema": "0.1.0-alpha.73",
34
34
  "@opentui/core": "^0.5.8",
35
35
  "@opentui/keymap": "0.5.8",
36
36
  "@opentui/react": "^0.5.8",
package/src/App.tsx CHANGED
@@ -21,8 +21,8 @@ import {
21
21
  type DiffViewMode,
22
22
  type QuickAction,
23
23
  } from "./config";
24
- import { loadSkills, SlashSkillsContext } from "./skills";
25
- import type { SlashItem } from "./slash-palette";
24
+ import { loadSkills, PaletteNamesContext, SlashSkillsContext } from "./skills";
25
+ import { mergeSlashItems, slashItemsFrom, type SlashItem } from "./slash-palette";
26
26
  import {
27
27
  composeTheme,
28
28
  DEFAULT_THEME_NAME,
@@ -490,6 +490,10 @@ export function App({
490
490
  const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
491
491
  const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
492
492
  const [skills, setSkills] = useState<SlashItem[]>([]);
493
+ const paletteNames = useMemo(
494
+ () => new Set(mergeSlashItems(slashItemsFrom(quickActions), skills).map((item) => item.name)),
495
+ [quickActions, skills],
496
+ );
493
497
  // opt-in: render a prototype as a kitty pixel mockup instead of the markdown doc
494
498
  const [prototypePixels, setPrototypePixels] = useState(false);
495
499
 
@@ -792,23 +796,25 @@ export function App({
792
796
  if (!session)
793
797
  return inbox ? (
794
798
  <SlashSkillsContext.Provider value={skills}>
795
- <NoThreadShell
796
- rows={grouped.rows}
797
- inboxCursor={inboxCursor}
798
- mode={mode}
799
- theme={theme}
800
- controller={controller}
801
- setMode={setMode}
802
- menuChrome={menuChrome}
803
- onOpenMenu={() => setMenuDialog("settings")}
804
- sidebarOpen={sidebarOpen}
805
- onToggleSidebar={() => setSidebarOpen((open) => !open)}
806
- pinnedIds={pinnedIds}
807
- onPin={togglePin}
808
- onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
809
- quickActions={quickActions}
810
- onWelcomeComposingChange={setWelcomeComposing}
811
- />
799
+ <PaletteNamesContext.Provider value={paletteNames}>
800
+ <NoThreadShell
801
+ rows={grouped.rows}
802
+ inboxCursor={inboxCursor}
803
+ mode={mode}
804
+ theme={theme}
805
+ controller={controller}
806
+ setMode={setMode}
807
+ menuChrome={menuChrome}
808
+ onOpenMenu={() => setMenuDialog("settings")}
809
+ sidebarOpen={sidebarOpen}
810
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
811
+ pinnedIds={pinnedIds}
812
+ onPin={togglePin}
813
+ onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
814
+ quickActions={quickActions}
815
+ onWelcomeComposingChange={setWelcomeComposing}
816
+ />
817
+ </PaletteNamesContext.Provider>
812
818
  </SlashSkillsContext.Provider>
813
819
  ) : (
814
820
  <ConnectingScreen theme={theme} />
@@ -890,221 +896,225 @@ export function App({
890
896
 
891
897
  return (
892
898
  <SlashSkillsContext.Provider value={skills}>
893
- <ThemeProvider theme={theme}>
894
- <AppShell
895
- sidebarOpen={sidebarOpen}
896
- onToggleSidebar={() => setSidebarOpen((open) => !open)}
897
- onOpenMenu={() => setMenuDialog("settings")}
898
- threadsPanel={
899
- <scrollbox style={{ flexGrow: 1 }} focused={false}>
900
- <ThreadTree
901
- rows={grouped.rows}
902
- cursor={inboxCursor}
903
- activeId={activeSession.id}
904
- pinnedIds={pinnedIds}
905
- width={30}
906
- onSelect={(id) => controller.open(id)}
907
- onPin={togglePin}
908
- onRename={(id, title) =>
909
- setMode({ type: "renameThread", sessionId: id, text: title })
910
- }
911
- theme={theme}
912
- />
913
- </scrollbox>
914
- }
915
- threadTitle={threadTitle(activeSession)}
916
- threadActions={
917
- showOwnerActions ? (
918
- <Toolbar>
919
- <Button onPress={onEditRequest} theme={theme}>
920
- {" edit "}
921
- </Button>
922
- <Button onPress={onShareRequest} theme={theme}>
923
- {" share "}
924
- </Button>
925
- </Toolbar>
926
- ) : undefined
927
- }
928
- threadPanel={
929
- <box style={{ flexGrow: 1, flexDirection: "column" }}>
930
- <box style={{ flexGrow: 1, flexDirection: "row" }}>
931
- {isPixelPrototype ? (
932
- <PrototypePixels
933
- prototypePath={prototypePath}
934
- canComment={prototypeCanComment}
935
- onCommentElement={onCommentPrototype}
936
- onComposingChange={setPrototypeComposing}
937
- hidden={chromeHidden}
938
- />
939
- ) : isDiff ? (
940
- <box style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }}>
941
- <text fg={theme.textDim}>review the changes on the right</text>
942
- </box>
943
- ) : (
944
- <ThreadView
945
- session={activeSession}
946
- suspended={threadViewSuspended}
947
- editOrphanCount={editOrphanCount}
948
- onComposingChange={setThreadComposing}
949
- resolved={resolved}
950
- onObserverBlocked={(reason) =>
951
- controller.setStatus(
952
- reason === "observer"
953
- ? "observer - read-only"
954
- : "review submitted - read-only",
955
- )
956
- }
957
- onCursorChange={setCursor}
958
- focusedAnnotationId={focusedAnnotationId}
959
- onFocusAnnotation={setFocusedAnnotationId}
960
- display={display}
961
- marks={marks}
962
- quickActions={quickActions}
963
- observer={observer}
964
- onAnnotate={(span, body) =>
965
- void controller.annotate(
966
- "comment",
967
- span.start.blockIndex,
968
- span.start.char,
969
- span.end.char,
899
+ <PaletteNamesContext.Provider value={paletteNames}>
900
+ <ThemeProvider theme={theme}>
901
+ <AppShell
902
+ sidebarOpen={sidebarOpen}
903
+ onToggleSidebar={() => setSidebarOpen((open) => !open)}
904
+ onOpenMenu={() => setMenuDialog("settings")}
905
+ threadsPanel={
906
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
907
+ <ThreadTree
908
+ rows={grouped.rows}
909
+ cursor={inboxCursor}
910
+ activeId={activeSession.id}
911
+ pinnedIds={pinnedIds}
912
+ width={30}
913
+ onSelect={(id) => controller.open(id)}
914
+ onPin={togglePin}
915
+ onRename={(id, title) =>
916
+ setMode({ type: "renameThread", sessionId: id, text: title })
917
+ }
918
+ theme={theme}
919
+ />
920
+ </scrollbox>
921
+ }
922
+ threadTitle={threadTitle(activeSession)}
923
+ threadActions={
924
+ showOwnerActions ? (
925
+ <Toolbar>
926
+ <Button onPress={onEditRequest} theme={theme}>
927
+ {" edit "}
928
+ </Button>
929
+ <Button onPress={onShareRequest} theme={theme}>
930
+ {" share "}
931
+ </Button>
932
+ </Toolbar>
933
+ ) : undefined
934
+ }
935
+ threadPanel={
936
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
937
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
938
+ {isPixelPrototype ? (
939
+ <PrototypePixels
940
+ prototypePath={prototypePath}
941
+ canComment={prototypeCanComment}
942
+ onCommentElement={onCommentPrototype}
943
+ onComposingChange={setPrototypeComposing}
944
+ hidden={chromeHidden}
945
+ />
946
+ ) : isDiff ? (
947
+ <box style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }}>
948
+ <text fg={theme.textDim}>review the changes on the right</text>
949
+ </box>
950
+ ) : (
951
+ <ThreadView
952
+ session={activeSession}
953
+ suspended={threadViewSuspended}
954
+ editOrphanCount={editOrphanCount}
955
+ onComposingChange={setThreadComposing}
956
+ resolved={resolved}
957
+ onObserverBlocked={(reason) =>
958
+ controller.setStatus(
959
+ reason === "observer"
960
+ ? "observer - read-only"
961
+ : "review submitted - read-only",
962
+ )
963
+ }
964
+ onCursorChange={setCursor}
965
+ focusedAnnotationId={focusedAnnotationId}
966
+ onFocusAnnotation={setFocusedAnnotationId}
967
+ display={display}
968
+ marks={marks}
969
+ quickActions={quickActions}
970
+ observer={observer}
971
+ onAnnotate={(span, body) =>
972
+ void controller.annotate(
973
+ "comment",
974
+ span.start.blockIndex,
975
+ span.start.char,
976
+ span.end.char,
977
+ body,
978
+ span.end.blockIndex,
979
+ )
980
+ }
981
+ onReply={(rootAnnotationId, body) =>
982
+ void controller.reply(rootAnnotationId, body)
983
+ }
984
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
985
+ onExit={() => onExit?.(0)}
986
+ />
987
+ )}
988
+ </box>
989
+ {threadFooter}
990
+ </box>
991
+ }
992
+ changesOpen={workbench.changesOpen}
993
+ projectOpen={workbench.projectOpen}
994
+ onToggleChanges={workbench.toggleChanges}
995
+ onToggleProject={workbench.toggleProject}
996
+ onToggleRight={workbench.toggleRight}
997
+ projectMode={workbench.projectMode}
998
+ zoomHideThread={workbench.zoomed}
999
+ changesFooter={threadFooter}
1000
+ changesPanel={
1001
+ <EditorGrid
1002
+ tree={workbench.grid}
1003
+ focusedGroupId={workbench.activeGroup}
1004
+ commentCounts={diffCommentCounts}
1005
+ onFocusGroup={workbench.focusGroup}
1006
+ onActivateTab={workbench.activate}
1007
+ onCloseTab={workbench.close}
1008
+ onSplit={workbench.split}
1009
+ onZoom={workbench.toggleZoom}
1010
+ zoomed={workbench.zoomed}
1011
+ renderTab={(tab, groupFocused) => (
1012
+ <GridTabContent
1013
+ tab={tab}
1014
+ rows={rows}
1015
+ surface={{
1016
+ session: activeSession,
1017
+ quickActions,
1018
+ observer,
1019
+ commentsEnabled: true,
1020
+ resolved,
1021
+ suspended: threadViewSuspended || !groupFocused,
1022
+ onComposingChange: setThreadComposing,
1023
+ onObserverBlocked: (reason) =>
1024
+ controller.setStatus(
1025
+ reason === "observer"
1026
+ ? "observer - read-only"
1027
+ : "review submitted - read-only",
1028
+ ),
1029
+ onCursorChange: setCursor,
1030
+ focusedAnnotationId,
1031
+ onFocusAnnotation: setFocusedAnnotationId,
1032
+ // a diff thread's Changes view is the artifact itself; any other thread's is the
1033
+ // live working-tree diff, so its notes carry a file target and anchor there
1034
+ onAnnotate: (span, body) =>
1035
+ void controller.annotate(
1036
+ "comment",
1037
+ span.start.blockIndex,
1038
+ span.start.char,
1039
+ span.end.char,
1040
+ body,
1041
+ span.end.blockIndex,
1042
+ isDiff
1043
+ ? { kind: "artifact" }
1044
+ : { kind: "file", path: "", rev: "worktree" },
1045
+ ),
1046
+ onReply: (rootAnnotationId, body) =>
1047
+ void controller.reply(rootAnnotationId, body),
1048
+ onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1049
+ onExit: () => onExit?.(0),
1050
+ }}
1051
+ rejectedRows={rejectedRows}
1052
+ fold={diffFold}
1053
+ fileStats={controller.fileStats()}
1054
+ split={diffView === "split" && workbench.zoomed}
1055
+ dimmed={walking}
1056
+ readFile={(path) => controller.repoReadFile(path)}
1057
+ onAddFileComment={(path, anchor, body) =>
1058
+ void controller.addComment(
1059
+ anchor,
1060
+ { kind: "file", path, rev: "worktree" },
970
1061
  body,
971
- span.end.blockIndex,
972
1062
  )
973
1063
  }
974
- onReply={(rootAnnotationId, body) =>
975
- void controller.reply(rootAnnotationId, body)
976
- }
977
- onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
978
- onExit={() => onExit?.(0)}
1064
+ theme={theme}
979
1065
  />
980
1066
  )}
981
- </box>
982
- {threadFooter}
983
- </box>
984
- }
985
- changesOpen={workbench.changesOpen}
986
- projectOpen={workbench.projectOpen}
987
- onToggleChanges={workbench.toggleChanges}
988
- onToggleProject={workbench.toggleProject}
989
- onToggleRight={workbench.toggleRight}
990
- projectMode={workbench.projectMode}
991
- zoomHideThread={workbench.zoomed}
992
- changesFooter={threadFooter}
993
- changesPanel={
994
- <EditorGrid
995
- tree={workbench.grid}
996
- focusedGroupId={workbench.activeGroup}
997
- commentCounts={diffCommentCounts}
998
- onFocusGroup={workbench.focusGroup}
999
- onActivateTab={workbench.activate}
1000
- onCloseTab={workbench.close}
1001
- onSplit={workbench.split}
1002
- onZoom={workbench.toggleZoom}
1003
- zoomed={workbench.zoomed}
1004
- renderTab={(tab, groupFocused) => (
1005
- <GridTabContent
1006
- tab={tab}
1007
- rows={rows}
1008
- surface={{
1009
- session: activeSession,
1010
- quickActions,
1011
- observer,
1012
- commentsEnabled: true,
1013
- resolved,
1014
- suspended: threadViewSuspended || !groupFocused,
1015
- onComposingChange: setThreadComposing,
1016
- onObserverBlocked: (reason) =>
1017
- controller.setStatus(
1018
- reason === "observer"
1019
- ? "observer - read-only"
1020
- : "review submitted - read-only",
1021
- ),
1022
- onCursorChange: setCursor,
1023
- focusedAnnotationId,
1024
- onFocusAnnotation: setFocusedAnnotationId,
1025
- // a diff thread's Changes view is the artifact itself; any other thread's is the
1026
- // live working-tree diff, so its notes carry a file target and anchor there
1027
- onAnnotate: (span, body) =>
1028
- void controller.annotate(
1029
- "comment",
1030
- span.start.blockIndex,
1031
- span.start.char,
1032
- span.end.char,
1033
- body,
1034
- span.end.blockIndex,
1035
- isDiff ? { kind: "artifact" } : { kind: "file", path: "", rev: "worktree" },
1036
- ),
1037
- onReply: (rootAnnotationId, body) =>
1038
- void controller.reply(rootAnnotationId, body),
1039
- onUpdateAnnotation: (id, body) => controller.updateAnnotation(id, body),
1040
- onExit: () => onExit?.(0),
1041
- }}
1042
- rejectedRows={rejectedRows}
1043
- fold={diffFold}
1044
- fileStats={controller.fileStats()}
1045
- split={diffView === "split" && workbench.zoomed}
1046
- dimmed={walking}
1047
- readFile={(path) => controller.repoReadFile(path)}
1048
- onAddFileComment={(path, anchor, body) =>
1049
- void controller.addComment(
1050
- anchor,
1051
- { kind: "file", path, rev: "worktree" },
1052
- body,
1053
- )
1054
- }
1055
- theme={theme}
1056
- />
1057
- )}
1058
- theme={theme}
1059
- />
1060
- }
1061
- projectPanel={
1062
- <ProjectPanelBody
1063
- mode={workbench.projectMode}
1064
- loadChanges={() => controller.repoChanges()}
1065
- loadProjectFiles={() => controller.repoFiles()}
1066
- reloadKey={activeSession.id}
1067
- // the Changes navigator always opens a changed file as a diff - a diff review shows its
1068
- // captured snapshot, every other thread the live working-tree diff
1069
- onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
1070
- onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
1071
- commentCounts={diffCommentCounts}
1067
+ theme={theme}
1068
+ />
1069
+ }
1070
+ projectPanel={
1071
+ <ProjectPanelBody
1072
+ mode={workbench.projectMode}
1073
+ loadChanges={() => controller.repoChanges()}
1074
+ loadProjectFiles={() => controller.repoFiles()}
1075
+ reloadKey={activeSession.id}
1076
+ // the Changes navigator always opens a changed file as a diff - a diff review shows its
1077
+ // captured snapshot, every other thread the live working-tree diff
1078
+ onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
1079
+ onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
1080
+ commentCounts={diffCommentCounts}
1081
+ theme={theme}
1082
+ />
1083
+ }
1084
+ theme={theme}
1085
+ >
1086
+ <TrailingOverlays
1087
+ walking={walking}
1088
+ walk={walk}
1089
+ walkFileList={walkFileList}
1090
+ viewedPaths={viewedPaths}
1091
+ session={activeSession}
1092
+ terminalWidth={terminalWidth}
1072
1093
  theme={theme}
1094
+ mode={mode}
1095
+ toast={toast}
1096
+ setMode={setMode}
1097
+ dispatch={dispatch}
1073
1098
  />
1074
- }
1075
- theme={theme}
1076
- >
1077
- <TrailingOverlays
1078
- walking={walking}
1079
- walk={walk}
1080
- walkFileList={walkFileList}
1081
- viewedPaths={viewedPaths}
1082
- session={activeSession}
1083
- terminalWidth={terminalWidth}
1084
- theme={theme}
1085
- mode={mode}
1086
- toast={toast}
1087
- setMode={setMode}
1088
- dispatch={dispatch}
1089
- />
1090
- {submitConfirmState !== null ? (
1091
- <box
1092
- style={{
1093
- position: "absolute",
1094
- left: 0,
1095
- top: 0,
1096
- width: "100%",
1097
- height: "100%",
1098
- justifyContent: "center",
1099
- alignItems: "center",
1100
- }}
1101
- >
1102
- <ConfirmCard {...submitConfirmState} theme={theme} />
1103
- </box>
1104
- ) : null}
1105
- {menuChrome}
1106
- </AppShell>
1107
- </ThemeProvider>
1099
+ {submitConfirmState !== null ? (
1100
+ <box
1101
+ style={{
1102
+ position: "absolute",
1103
+ left: 0,
1104
+ top: 0,
1105
+ width: "100%",
1106
+ height: "100%",
1107
+ justifyContent: "center",
1108
+ alignItems: "center",
1109
+ }}
1110
+ >
1111
+ <ConfirmCard {...submitConfirmState} theme={theme} />
1112
+ </box>
1113
+ ) : null}
1114
+ {menuChrome}
1115
+ </AppShell>
1116
+ </ThemeProvider>
1117
+ </PaletteNamesContext.Provider>
1108
1118
  </SlashSkillsContext.Provider>
1109
1119
  );
1110
1120
  }
@@ -78,7 +78,6 @@ export const SlashPaletteOpen: Story = {
78
78
  slashActive
79
79
  slashItems={slashItemsFrom(DEFAULT_QUICK_ACTIONS)}
80
80
  slashIndex={1}
81
- inline={null}
82
81
  tokens={DARK}
83
82
  />
84
83
  ),
@@ -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
  }
@@ -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). */
@@ -38,8 +38,9 @@ import {
38
38
  import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
39
39
  import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs";
40
40
  import {
41
+ activeSlashToken,
42
+ isStandaloneSlashQuery,
41
43
  mergeSlashItems,
42
- resolveInlineSuggestion,
43
44
  slashFilter,
44
45
  slashItemsFrom,
45
46
  } from "./slash-palette";
@@ -233,6 +234,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
233
234
  };
234
235
  const [folded, setFolded] = useState<Set<string>>(new Set());
235
236
  const [composeText, setComposeText] = useState("");
237
+ const [caretOffset, setCaretOffset] = useState(0);
236
238
  const [slashIndex, setSlashIndex] = useState(0);
237
239
  const composerReady = useRef(false);
238
240
  const composeRef = useRef<ComposeState | null>(null);
@@ -304,19 +306,26 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
304
306
  composeRef.current = state;
305
307
  setCompose(state);
306
308
  setComposeText(state.seed);
309
+ setCaretOffset(state.seed.length);
307
310
  setSlashIndex(0);
308
311
  };
309
312
  const closeCompose = (): void => {
310
313
  composeRef.current = null;
311
314
  setCompose(null);
312
315
  setComposeText("");
316
+ setCaretOffset(0);
313
317
  };
314
318
  const skills = useContext(SlashSkillsContext);
315
319
  const paletteItems = mergeSlashItems(slashItemsFrom(quickActions), skills);
316
- // open only while typing the token: a leading "/" with no space yet
317
- const slashActive = compose !== null && /^\/\S*$/.test(composeText);
318
- const slashItems = slashActive ? slashFilter(paletteItems, composeText.slice(1).trim()) : [];
319
- const inlineSlash = resolveInlineSuggestion(slashActive, composeText, paletteItems);
320
+ // the "/word" under the caret, anywhere in the draft, so each new "/" reopens the palette
321
+ const slashToken = compose !== null ? activeSlashToken(composeText, caretOffset) : null;
322
+ const slashActive = slashToken !== null;
323
+ const slashItems = slashToken !== null ? slashFilter(paletteItems, slashToken.slice(1)) : [];
324
+
325
+ // a fresh token starts its selection at the top, so an earlier Down never leaks into it
326
+ useEffect(() => {
327
+ if (slashActive) setSlashIndex(0);
328
+ }, [slashActive]);
320
329
 
321
330
  const saveComment = (body: string): void => {
322
331
  // the body saves verbatim - typed newlines are the author's choice;
@@ -357,11 +366,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
357
366
  });
358
367
  };
359
368
 
360
- // clicking away from an open composer commits the draft (blur-save);
361
- // a half-typed slash query is never a comment, so it discards instead
369
+ // clicking away commits the draft (blur-save); only a standalone "/query" is a palette
370
+ // artifact, so prose that merely ends in a "/name" still saves
362
371
  const blurSaveCompose = (): void => {
363
372
  if (!composeRef.current) return;
364
- if (slashActive || composeText.trim().length === 0) return closeCompose();
373
+ if (isStandaloneSlashQuery(composeText) || composeText.trim().length === 0) {
374
+ return closeCompose();
375
+ }
365
376
  saveComment(composeText);
366
377
  };
367
378
 
@@ -431,7 +442,14 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
431
442
  return true;
432
443
  }
433
444
  if (key.name === "return" || key.name === "tab") {
434
- openCompose({ ...activeCompose, seed: `/${slashItems[selected]!.name} ` });
445
+ const token = activeSlashToken(composeText, caretOffset) ?? "";
446
+ const cut = caretOffset - token.length;
447
+ const insertion = `/${slashItems[selected]!.name} `;
448
+ const seed = composeText.slice(0, cut) + insertion + composeText.slice(caretOffset);
449
+
450
+ openCompose({ ...activeCompose, seed });
451
+ // land the caret just after the inserted reference, not at the end of a chained draft
452
+ setCaretOffset(cut + insertion.length);
435
453
 
436
454
  return true;
437
455
  }
@@ -439,17 +457,6 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
439
457
  return false;
440
458
  };
441
459
 
442
- /** Tab completes the trailing "/word" to the matched skill's full name, then a space to chain. */
443
- const handleInlineSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
444
- if (inlineSlash === null || key.name !== "tab") return false;
445
- const cut = composeText.length - inlineSlash.token.length;
446
- const completed = `${composeText.slice(0, cut)}/${inlineSlash.suggestion.name} `;
447
-
448
- openCompose({ ...activeCompose, seed: completed });
449
-
450
- return true;
451
- };
452
-
453
460
  /** Pre-mount window: buffer printables, honor a fast cmd+enter or newline. */
454
461
  const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
455
462
  if (key.name === "return") {
@@ -458,6 +465,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
458
465
 
459
466
  composeRef.current = grown;
460
467
  setComposeText(grown.seed);
468
+ setCaretOffset(grown.seed.length);
461
469
 
462
470
  return setCompose(grown);
463
471
  }
@@ -468,6 +476,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
468
476
 
469
477
  composeRef.current = grown;
470
478
  setComposeText(grown.seed);
479
+ setCaretOffset(grown.seed.length);
471
480
  setCompose(grown);
472
481
  }
473
482
  };
@@ -491,7 +500,6 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
491
500
  return closeCompose();
492
501
  }
493
502
  if (handleSlashKey(key, activeCompose)) return;
494
- if (handleInlineSlashKey(key, activeCompose)) return;
495
503
  if (!composerReady.current) handlePremountKey(key, activeCompose);
496
504
  };
497
505
 
@@ -670,18 +678,19 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
670
678
  tokens={tokens}
671
679
  onSave={saveComment}
672
680
  onReady={() => (composerReady.current = true)}
673
- onInput={setComposeText}
681
+ onInput={(text, caret) => {
682
+ setComposeText(text);
683
+ setCaretOffset(caret);
684
+ }}
674
685
  />
675
686
  ) : null;
676
- // one node below the composer: the palette list for a leading "/", otherwise the
677
- // inline completion hint. Renders null when neither applies, so it always pushes.
687
+ // the palette list below the composer while the caret is on a "/word"; null otherwise, so it pushes
678
688
  const paletteNode = (
679
689
  <ComposerPalette
680
690
  key="composer-palette"
681
691
  slashActive={slashActive}
682
692
  slashItems={slashItems}
683
693
  slashIndex={slashIndex}
684
- inline={inlineSlash}
685
694
  tokens={tokens}
686
695
  />
687
696
  );