@cueloop/client 0.1.0-alpha.71 → 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 +237 -227
- package/src/components/AnnotationCards.stories.tsx +0 -1
- package/src/components/AnnotationCards.tsx +34 -30
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.72",
|
|
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.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.72",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.72",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.72",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.72",
|
|
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
|
|
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
|
-
<
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
<
|
|
894
|
-
<
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
<
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
<
|
|
920
|
-
{
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
{
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
<box style={{ flexGrow: 1, flexDirection: "
|
|
931
|
-
{
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
<
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
}
|
|
@@ -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
|
}
|
|
@@ -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). */
|
|
@@ -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
|
-
//
|
|
317
|
-
const
|
|
318
|
-
const
|
|
319
|
-
const
|
|
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
|
|
361
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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={
|
|
681
|
+
onInput={(text, caret) => {
|
|
682
|
+
setComposeText(text);
|
|
683
|
+
setCaretOffset(caret);
|
|
684
|
+
}}
|
|
674
685
|
/>
|
|
675
686
|
) : null;
|
|
676
|
-
//
|
|
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
|
);
|