@skaile/forge-app-actions 0.0.0-stage → 0.1.0
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/README.md +34 -2
- package/dist/module.d.mts +23 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +37 -0
- package/dist/runtime/client-capabilities.d.ts +29 -0
- package/dist/runtime/client-capabilities.js +25 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.d.vue.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +125 -0
- package/dist/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue.d.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.d.vue.ts +2 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue +218 -0
- package/dist/runtime/components-nuxt-ui/ForgeCommandPalette.vue.d.ts +2 -0
- package/dist/runtime/composables/defineClientCapability.d.ts +64 -0
- package/dist/runtime/composables/defineClientCapability.js +40 -0
- package/dist/runtime/composables/defineForgeAction.d.ts +82 -0
- package/dist/runtime/composables/defineForgeAction.js +147 -0
- package/dist/runtime/composables/useAgentCapabilities.d.ts +78 -0
- package/dist/runtime/composables/useAgentCapabilities.js +94 -0
- package/dist/runtime/composables/useCapabilityApproval.d.ts +109 -0
- package/dist/runtime/composables/useCapabilityApproval.js +74 -0
- package/dist/runtime/composables/useCapabilityInvocations.d.ts +150 -0
- package/dist/runtime/composables/useCapabilityInvocations.js +129 -0
- package/dist/runtime/composables/useCommandPalette.d.ts +28 -0
- package/dist/runtime/composables/useCommandPalette.js +49 -0
- package/dist/runtime/composables/usePaletteArgStep.d.ts +47 -0
- package/dist/runtime/composables/usePaletteArgStep.js +51 -0
- package/dist/runtime/server/index.d.ts +7 -0
- package/dist/runtime/server/index.js +2 -0
- package/dist/runtime/server/utils/capability-registry.d.ts +35 -0
- package/dist/runtime/server/utils/capability-registry.js +24 -0
- package/dist/runtime/server/utils/client-capability-bridge.d.ts +647 -0
- package/dist/runtime/server/utils/client-capability-bridge.js +755 -0
- package/dist/runtime/shims-vue.d.ts +8 -0
- package/dist/runtime/types/client-capability.d.ts +159 -0
- package/dist/runtime/types/client-capability.js +0 -0
- package/dist/runtime/types/command-palette.d.ts +75 -0
- package/dist/runtime/types/command-palette.js +0 -0
- package/dist/runtime/types/forge-action.d.ts +152 -0
- package/dist/runtime/types/forge-action.js +0 -0
- package/dist/runtime/types/index.d.ts +3 -0
- package/dist/runtime/types/index.js +0 -0
- package/dist/types.d.mts +9 -0
- package/package.json +81 -4
- package/src/runtime/client-capabilities.ts +82 -0
- package/src/runtime/components-nuxt-ui/ForgeCapabilityApproval.vue +240 -0
- package/src/runtime/components-nuxt-ui/ForgeCommandPalette.vue +363 -0
- package/src/runtime/composables/defineClientCapability.ts +119 -0
- package/src/runtime/composables/defineForgeAction.ts +278 -0
- package/src/runtime/composables/useAgentCapabilities.ts +215 -0
- package/src/runtime/composables/useCapabilityApproval.ts +219 -0
- package/src/runtime/composables/useCapabilityInvocations.ts +343 -0
- package/src/runtime/composables/useCommandPalette.ts +97 -0
- package/src/runtime/composables/usePaletteArgStep.ts +110 -0
- package/src/runtime/server/index.ts +8 -0
- package/src/runtime/server/utils/capability-registry.ts +66 -0
- package/src/runtime/server/utils/client-capability-bridge.ts +1201 -0
- package/src/runtime/shims-vue.d.ts +8 -0
- package/src/runtime/types/client-capability.ts +178 -0
- package/src/runtime/types/command-palette.ts +78 -0
- package/src/runtime/types/forge-action.ts +166 -0
- package/src/runtime/types/index.ts +24 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ForgeCapabilityApproval — the confirm gate for `requiresApproval`
|
|
4
|
+
* capabilities, built on UModal + UButton.
|
|
5
|
+
*
|
|
6
|
+
* No props, no emits. All state lives in the `useCapabilityApproval` singleton,
|
|
7
|
+
* which `useCapabilityInvocations().handle()` feeds. Mount once in `app.vue`.
|
|
8
|
+
*
|
|
9
|
+
* Closing without choosing (Escape, backdrop) counts as a decline. The safe
|
|
10
|
+
* default for "should this thing touch your workspace?" is no.
|
|
11
|
+
*/
|
|
12
|
+
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
13
|
+
import { useCapabilityApproval } from "../composables/useCapabilityApproval";
|
|
14
|
+
|
|
15
|
+
const { current, approve, decline } = useCapabilityApproval();
|
|
16
|
+
|
|
17
|
+
const title = computed(() => current.value?.displayName || current.value?.name || "");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The arguments, as rows. An object becomes one row per key; anything else
|
|
21
|
+
* (a bare string, a number) becomes a single unnamed row. The user is
|
|
22
|
+
* approving a specific call, so the specific values have to be on screen.
|
|
23
|
+
*/
|
|
24
|
+
const argumentRows = computed<Array<{ key: string; value: string }>>(() => {
|
|
25
|
+
const input = current.value?.input;
|
|
26
|
+
if (input === undefined || input === null) return [];
|
|
27
|
+
if (typeof input === "object" && !Array.isArray(input)) {
|
|
28
|
+
return Object.entries(input as Record<string, unknown>).map(([key, value]) => ({
|
|
29
|
+
key,
|
|
30
|
+
value: format(value),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
return [{ key: "", value: format(input) }];
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function format(value: unknown): string {
|
|
37
|
+
if (typeof value === "string") return value;
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(value);
|
|
40
|
+
} catch {
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Countdown ──────────────────────────────────────────────────────────────
|
|
46
|
+
// A gate with an invisible deadline is a trap: the user needs to know the
|
|
47
|
+
// dialog will answer for them if they walk away.
|
|
48
|
+
const now = ref(Date.now());
|
|
49
|
+
// Started on mount: the component renders on the server too, where an
|
|
50
|
+
// interval would never be cleared.
|
|
51
|
+
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
52
|
+
onMounted(() => {
|
|
53
|
+
ticker = setInterval(() => {
|
|
54
|
+
now.value = Date.now();
|
|
55
|
+
}, 500);
|
|
56
|
+
});
|
|
57
|
+
onUnmounted(() => clearInterval(ticker));
|
|
58
|
+
|
|
59
|
+
const secondsLeft = computed<number | null>(() => {
|
|
60
|
+
const request = current.value;
|
|
61
|
+
if (request?.expiresAt === undefined) return null;
|
|
62
|
+
// Clamped to the declared window: `now` only ticks twice a second and is
|
|
63
|
+
// seeded at mount, which is earlier than any request it will ever show.
|
|
64
|
+
const remaining = Math.min(
|
|
65
|
+
request.expiresAt - now.value,
|
|
66
|
+
request.timeoutMs ?? Number.MAX_SAFE_INTEGER,
|
|
67
|
+
);
|
|
68
|
+
return Math.max(0, Math.ceil(remaining / 1000));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function onApprove() {
|
|
72
|
+
const id = current.value?.id;
|
|
73
|
+
if (id) approve(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onDecline() {
|
|
77
|
+
const id = current.value?.id;
|
|
78
|
+
if (id) decline(id);
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<UModal
|
|
84
|
+
:open="current !== null"
|
|
85
|
+
title="Allow this action?"
|
|
86
|
+
:ui="{ content: 'max-w-[480px] w-[95vw]' }"
|
|
87
|
+
@update:open="(v: boolean) => !v && onDecline()"
|
|
88
|
+
>
|
|
89
|
+
<template #content>
|
|
90
|
+
<div class="forge-capability-approval" data-testid="capability-approval" @keydown.esc.prevent="onDecline">
|
|
91
|
+
<h2 class="forge-capability-approval__heading">Allow this action?</h2>
|
|
92
|
+
|
|
93
|
+
<template v-if="current">
|
|
94
|
+
<p class="forge-capability-approval__intro">
|
|
95
|
+
The agent wants to run
|
|
96
|
+
<strong data-testid="capability-approval-title">{{ title }}</strong>
|
|
97
|
+
</p>
|
|
98
|
+
|
|
99
|
+
<code class="forge-capability-approval__name" data-testid="capability-approval-name">{{
|
|
100
|
+
current.name
|
|
101
|
+
}}</code>
|
|
102
|
+
|
|
103
|
+
<p
|
|
104
|
+
v-if="current.description"
|
|
105
|
+
class="forge-capability-approval__description"
|
|
106
|
+
data-testid="capability-approval-description"
|
|
107
|
+
>
|
|
108
|
+
{{ current.description }}
|
|
109
|
+
</p>
|
|
110
|
+
|
|
111
|
+
<div class="forge-capability-approval__args" data-testid="capability-approval-args">
|
|
112
|
+
<div class="forge-capability-approval__args-label">Arguments</div>
|
|
113
|
+
<div v-if="argumentRows.length === 0" class="forge-capability-approval__arg-empty">
|
|
114
|
+
none
|
|
115
|
+
</div>
|
|
116
|
+
<div
|
|
117
|
+
v-for="row in argumentRows"
|
|
118
|
+
:key="row.key"
|
|
119
|
+
class="forge-capability-approval__arg"
|
|
120
|
+
>
|
|
121
|
+
<span v-if="row.key" class="forge-capability-approval__arg-key">{{ row.key }}</span>
|
|
122
|
+
<span class="forge-capability-approval__arg-value">{{ row.value }}</span>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<p
|
|
127
|
+
v-if="secondsLeft !== null"
|
|
128
|
+
class="forge-capability-approval__countdown"
|
|
129
|
+
data-testid="capability-approval-countdown"
|
|
130
|
+
>
|
|
131
|
+
Expires in {{ secondsLeft }}s if unanswered.
|
|
132
|
+
</p>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<div class="forge-capability-approval__actions">
|
|
136
|
+
<!-- Decline is focused first on purpose: this dialog can appear while
|
|
137
|
+
the user is typing, and a stray Enter must not approve an action. -->
|
|
138
|
+
<UButton
|
|
139
|
+
label="Decline"
|
|
140
|
+
color="neutral"
|
|
141
|
+
variant="subtle"
|
|
142
|
+
autofocus
|
|
143
|
+
data-testid="capability-approval-decline"
|
|
144
|
+
@click="onDecline"
|
|
145
|
+
/>
|
|
146
|
+
<UButton label="Allow" data-testid="capability-approval-approve" @click="onApprove" />
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</template>
|
|
150
|
+
</UModal>
|
|
151
|
+
</template>
|
|
152
|
+
|
|
153
|
+
<style scoped>
|
|
154
|
+
.forge-capability-approval {
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-direction: column;
|
|
157
|
+
gap: 10px;
|
|
158
|
+
padding: 16px;
|
|
159
|
+
font-size: 0.9rem;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.forge-capability-approval__heading {
|
|
163
|
+
margin: 0;
|
|
164
|
+
font-size: 1rem;
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.forge-capability-approval__intro {
|
|
169
|
+
margin: 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.forge-capability-approval__name {
|
|
173
|
+
align-self: flex-start;
|
|
174
|
+
padding: 2px 6px;
|
|
175
|
+
border-radius: 4px;
|
|
176
|
+
background: #f1f5f9;
|
|
177
|
+
font-size: 0.8rem;
|
|
178
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
179
|
+
color: #475569;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.forge-capability-approval__description {
|
|
183
|
+
margin: 0;
|
|
184
|
+
color: #64748b;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.forge-capability-approval__args {
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-direction: column;
|
|
190
|
+
gap: 4px;
|
|
191
|
+
padding: 8px 10px;
|
|
192
|
+
border-radius: 6px;
|
|
193
|
+
background: #f8fafc;
|
|
194
|
+
border: 1px solid #e2e8f0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.forge-capability-approval__args-label {
|
|
198
|
+
font-size: 0.7rem;
|
|
199
|
+
font-weight: 700;
|
|
200
|
+
text-transform: uppercase;
|
|
201
|
+
letter-spacing: 0.08em;
|
|
202
|
+
color: #94a3b8;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.forge-capability-approval__arg {
|
|
206
|
+
display: flex;
|
|
207
|
+
gap: 8px;
|
|
208
|
+
align-items: baseline;
|
|
209
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
210
|
+
font-size: 0.8rem;
|
|
211
|
+
word-break: break-all;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.forge-capability-approval__arg-key {
|
|
215
|
+
color: #64748b;
|
|
216
|
+
flex-shrink: 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.forge-capability-approval__arg-key::after {
|
|
220
|
+
content: ":";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.forge-capability-approval__arg-empty {
|
|
224
|
+
color: #94a3b8;
|
|
225
|
+
font-size: 0.8rem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.forge-capability-approval__countdown {
|
|
229
|
+
margin: 0;
|
|
230
|
+
font-size: 0.75rem;
|
|
231
|
+
color: #94a3b8;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.forge-capability-approval__actions {
|
|
235
|
+
display: flex;
|
|
236
|
+
justify-content: flex-end;
|
|
237
|
+
gap: 8px;
|
|
238
|
+
margin-top: 4px;
|
|
239
|
+
}
|
|
240
|
+
</style>
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ForgeCommandPalette — global Cmd+K / Ctrl+K command palette, built on
|
|
4
|
+
* UModal (backdrop) + UInput (search).
|
|
5
|
+
*
|
|
6
|
+
* No props, no emits. All state lives in the useCommandPalette singleton.
|
|
7
|
+
* Mount once in app.vue (outside any auth gate).
|
|
8
|
+
*/
|
|
9
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
10
|
+
import { useCommandPalette } from "../composables/useCommandPalette";
|
|
11
|
+
import { usePaletteArgStep } from "../composables/usePaletteArgStep";
|
|
12
|
+
import type { PaletteAction } from "../types/command-palette";
|
|
13
|
+
|
|
14
|
+
const { queryActions, isOpen, open, close } = useCommandPalette();
|
|
15
|
+
|
|
16
|
+
// ── Search input ───────────────────────────────────────────────────────────
|
|
17
|
+
const search = ref("");
|
|
18
|
+
const selectedIndex = ref(0);
|
|
19
|
+
const searchInputRef = ref<{ inputRef?: HTMLInputElement } | null>(null);
|
|
20
|
+
|
|
21
|
+
// ── Argument step ──────────────────────────────────────────────────────────
|
|
22
|
+
// An action that declares `arg` needs one string before it can run. Selecting
|
|
23
|
+
// it swaps the list for its candidates and waits for a second Enter; Escape
|
|
24
|
+
// steps back to the action list rather than closing the palette.
|
|
25
|
+
const argStep = usePaletteArgStep();
|
|
26
|
+
|
|
27
|
+
// ── Filtered results ───────────────────────────────────────────────────────
|
|
28
|
+
const filteredActions = computed(() => (argStep.active.value ? [] : queryActions(search.value)));
|
|
29
|
+
|
|
30
|
+
/** Rows currently rendered — actions, or the pending action's candidates. */
|
|
31
|
+
const rowCount = computed(() =>
|
|
32
|
+
argStep.active.value ? argStep.options.value.length : filteredActions.value.length,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Reset selection when results change
|
|
36
|
+
watch(filteredActions, () => {
|
|
37
|
+
selectedIndex.value = 0;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Typing during the argument step re-queries the action's candidates.
|
|
41
|
+
watch(search, (value) => {
|
|
42
|
+
if (!argStep.active.value) return;
|
|
43
|
+
selectedIndex.value = 0;
|
|
44
|
+
void argStep.query(value);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Reset search when dialog closes
|
|
48
|
+
watch(isOpen, (open) => {
|
|
49
|
+
if (!open) {
|
|
50
|
+
search.value = "";
|
|
51
|
+
selectedIndex.value = 0;
|
|
52
|
+
argStep.cancel();
|
|
53
|
+
} else {
|
|
54
|
+
// Focus input after dialog opens (guard: stub / no-op in tests)
|
|
55
|
+
nextTick(() => focusSearch());
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Focus the palette input. Guarded: a stubbed ref in tests is a no-op. */
|
|
60
|
+
function focusSearch() {
|
|
61
|
+
searchInputRef.value?.inputRef?.focus();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Group headers ──────────────────────────────────────────────────────────
|
|
65
|
+
function getGroupHeader(index: number): string | null {
|
|
66
|
+
const action = filteredActions.value[index];
|
|
67
|
+
if (!action?.group) return null;
|
|
68
|
+
if (index === 0) return action.group;
|
|
69
|
+
const prev = filteredActions.value[index - 1];
|
|
70
|
+
if (prev?.group !== action.group) return action.group;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Keyboard navigation ────────────────────────────────────────────────────
|
|
75
|
+
function handleDialogKeydown(e: KeyboardEvent) {
|
|
76
|
+
// Escape and Enter must work even with an empty list: Escape backs out of the
|
|
77
|
+
// argument step, and a free-text argument has no rows to highlight.
|
|
78
|
+
if (e.key === "Escape") {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
if (argStep.active.value) {
|
|
81
|
+
argStep.cancel();
|
|
82
|
+
search.value = "";
|
|
83
|
+
selectedIndex.value = 0;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
close();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const count = rowCount.value;
|
|
91
|
+
|
|
92
|
+
if (e.key === "ArrowDown") {
|
|
93
|
+
if (count === 0) return;
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
selectedIndex.value = (selectedIndex.value + 1) % count;
|
|
96
|
+
} else if (e.key === "ArrowUp") {
|
|
97
|
+
if (count === 0) return;
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
selectedIndex.value = (selectedIndex.value - 1 + count) % count;
|
|
100
|
+
} else if (e.key === "Enter") {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
if (argStep.active.value) {
|
|
103
|
+
void submitArg();
|
|
104
|
+
} else {
|
|
105
|
+
void selectAction(filteredActions.value[selectedIndex.value]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A thrown action must not become an unhandled rejection — the palette has
|
|
112
|
+
* already closed by then and there is nothing left to show it on.
|
|
113
|
+
*/
|
|
114
|
+
async function runAction(action: PaletteAction, value?: string) {
|
|
115
|
+
try {
|
|
116
|
+
await action.handler(value);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error(`[ForgeCommandPalette] action "${action.id}" failed:`, err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function selectAction(action: PaletteAction | undefined) {
|
|
123
|
+
if (!action) return;
|
|
124
|
+
// An action that needs an argument keeps the palette open for the second step.
|
|
125
|
+
if (argStep.begin(action)) {
|
|
126
|
+
search.value = "";
|
|
127
|
+
selectedIndex.value = 0;
|
|
128
|
+
nextTick(() => focusSearch());
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
close();
|
|
132
|
+
await runAction(action);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Complete the argument step with the highlighted row or the typed value. */
|
|
136
|
+
async function submitArg() {
|
|
137
|
+
const action = argStep.action.value;
|
|
138
|
+
if (!action) return;
|
|
139
|
+
const value = argStep.valueFor(search.value, selectedIndex.value);
|
|
140
|
+
if (value === null) return; // nothing to run with yet
|
|
141
|
+
argStep.cancel();
|
|
142
|
+
close();
|
|
143
|
+
await runAction(action, value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Click on a candidate row during the argument step. */
|
|
147
|
+
async function selectArgOption(index: number) {
|
|
148
|
+
selectedIndex.value = index;
|
|
149
|
+
await submitArg();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Global Cmd+K shortcut ──────────────────────────────────────────────────
|
|
153
|
+
function handleGlobalKeydown(e: KeyboardEvent) {
|
|
154
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
open();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
onMounted(() => {
|
|
161
|
+
document.addEventListener("keydown", handleGlobalKeydown);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
onUnmounted(() => {
|
|
165
|
+
document.removeEventListener("keydown", handleGlobalKeydown);
|
|
166
|
+
});
|
|
167
|
+
</script>
|
|
168
|
+
|
|
169
|
+
<template>
|
|
170
|
+
<!-- Nuxt UI Modal as the palette backdrop. #content gives full control of the
|
|
171
|
+
panel (no default header/footer). -->
|
|
172
|
+
<UModal
|
|
173
|
+
:open="isOpen"
|
|
174
|
+
:ui="{ content: 'max-w-[560px] w-[95vw] p-0' }"
|
|
175
|
+
@update:open="(v: boolean) => !v && close()"
|
|
176
|
+
>
|
|
177
|
+
<template #content>
|
|
178
|
+
<div class="forge-command-palette" @keydown="handleDialogKeydown">
|
|
179
|
+
<div v-if="argStep.active.value" class="forge-command-palette__breadcrumb">
|
|
180
|
+
{{ argStep.prompt.value?.label ?? argStep.action.value?.label }}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<!-- Search input -->
|
|
184
|
+
<div class="forge-command-palette__search">
|
|
185
|
+
<UInput
|
|
186
|
+
ref="searchInputRef"
|
|
187
|
+
v-model="search"
|
|
188
|
+
:placeholder="argStep.active.value
|
|
189
|
+
? (argStep.prompt.value?.placeholder ?? 'Type a value…')
|
|
190
|
+
: 'Search commands…'"
|
|
191
|
+
variant="none"
|
|
192
|
+
autofocus
|
|
193
|
+
:ui="{ root: 'w-full' }"
|
|
194
|
+
class="forge-command-palette__input"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<!-- Results list -->
|
|
199
|
+
<div class="forge-command-palette__list" role="listbox">
|
|
200
|
+
<!-- Argument step: the pending action's candidates -->
|
|
201
|
+
<template v-if="argStep.active.value">
|
|
202
|
+
<div v-if="argStep.options.value.length === 0" class="forge-command-palette__empty">
|
|
203
|
+
{{ argStep.loading.value ? "Searching…" : "Type a value and press Enter" }}
|
|
204
|
+
</div>
|
|
205
|
+
<button
|
|
206
|
+
v-for="(option, index) in argStep.options.value"
|
|
207
|
+
:key="option.value"
|
|
208
|
+
type="button"
|
|
209
|
+
class="forge-command-palette__item"
|
|
210
|
+
:class="{ 'forge-command-palette__item--selected': index === selectedIndex }"
|
|
211
|
+
role="option"
|
|
212
|
+
:aria-selected="index === selectedIndex"
|
|
213
|
+
@click="selectArgOption(index)"
|
|
214
|
+
@mouseenter="selectedIndex = index"
|
|
215
|
+
>
|
|
216
|
+
<span v-if="option.icon" class="forge-command-palette__icon">
|
|
217
|
+
<Icon :name="option.icon" />
|
|
218
|
+
</span>
|
|
219
|
+
<span class="forge-command-palette__text">
|
|
220
|
+
<span class="forge-command-palette__label">{{ option.label }}</span>
|
|
221
|
+
<span
|
|
222
|
+
v-if="option.description"
|
|
223
|
+
class="forge-command-palette__description"
|
|
224
|
+
>{{ option.description }}</span>
|
|
225
|
+
</span>
|
|
226
|
+
</button>
|
|
227
|
+
</template>
|
|
228
|
+
|
|
229
|
+
<template v-if="!argStep.active.value && filteredActions.length === 0">
|
|
230
|
+
<div class="forge-command-palette__empty">No commands found</div>
|
|
231
|
+
</template>
|
|
232
|
+
|
|
233
|
+
<template v-for="(action, index) in filteredActions" :key="action.id">
|
|
234
|
+
<!-- Group header -->
|
|
235
|
+
<div
|
|
236
|
+
v-if="getGroupHeader(index)"
|
|
237
|
+
class="forge-command-palette__group"
|
|
238
|
+
>
|
|
239
|
+
{{ getGroupHeader(index) }}
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<!-- Action row -->
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
class="forge-command-palette__item"
|
|
246
|
+
:class="{ 'forge-command-palette__item--selected': index === selectedIndex }"
|
|
247
|
+
role="option"
|
|
248
|
+
:aria-selected="index === selectedIndex"
|
|
249
|
+
@click="selectAction(action)"
|
|
250
|
+
@mouseenter="selectedIndex = index"
|
|
251
|
+
>
|
|
252
|
+
<span v-if="action.icon" class="forge-command-palette__icon">
|
|
253
|
+
<Icon :name="action.icon" />
|
|
254
|
+
</span>
|
|
255
|
+
<span class="forge-command-palette__text">
|
|
256
|
+
<span class="forge-command-palette__label">{{ action.label }}</span>
|
|
257
|
+
<span
|
|
258
|
+
v-if="action.description"
|
|
259
|
+
class="forge-command-palette__description"
|
|
260
|
+
>{{ action.description }}</span>
|
|
261
|
+
</span>
|
|
262
|
+
</button>
|
|
263
|
+
</template>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</template>
|
|
267
|
+
</UModal>
|
|
268
|
+
</template>
|
|
269
|
+
|
|
270
|
+
<style scoped>
|
|
271
|
+
.forge-command-palette__breadcrumb {
|
|
272
|
+
padding: 10px 12px 0;
|
|
273
|
+
font-size: 0.75rem;
|
|
274
|
+
font-weight: 600;
|
|
275
|
+
color: #64748b;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.forge-command-palette {
|
|
279
|
+
display: flex;
|
|
280
|
+
flex-direction: column;
|
|
281
|
+
overflow: hidden;
|
|
282
|
+
border-radius: 8px;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.forge-command-palette__search {
|
|
286
|
+
padding: 12px;
|
|
287
|
+
border-bottom: 1px solid #e2e8f0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.forge-command-palette__input {
|
|
291
|
+
width: 100%;
|
|
292
|
+
font-size: 0.95rem;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.forge-command-palette__list {
|
|
296
|
+
max-height: 360px;
|
|
297
|
+
overflow-y: auto;
|
|
298
|
+
padding: 6px;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.forge-command-palette__empty {
|
|
302
|
+
padding: 16px;
|
|
303
|
+
text-align: center;
|
|
304
|
+
color: #94a3b8;
|
|
305
|
+
font-size: 0.875rem;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.forge-command-palette__group {
|
|
309
|
+
padding: 4px 8px 2px;
|
|
310
|
+
font-size: 0.7rem;
|
|
311
|
+
font-weight: 700;
|
|
312
|
+
text-transform: uppercase;
|
|
313
|
+
letter-spacing: 0.08em;
|
|
314
|
+
color: #94a3b8;
|
|
315
|
+
margin-top: 4px;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.forge-command-palette__item {
|
|
319
|
+
display: flex;
|
|
320
|
+
align-items: center;
|
|
321
|
+
gap: 10px;
|
|
322
|
+
width: 100%;
|
|
323
|
+
padding: 8px 10px;
|
|
324
|
+
border: none;
|
|
325
|
+
border-radius: 6px;
|
|
326
|
+
background: transparent;
|
|
327
|
+
cursor: pointer;
|
|
328
|
+
text-align: left;
|
|
329
|
+
font: inherit;
|
|
330
|
+
color: inherit;
|
|
331
|
+
transition: background 0.1s ease;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.forge-command-palette__item--selected {
|
|
335
|
+
background: #f1f5f9;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.forge-command-palette__icon {
|
|
339
|
+
width: 20px;
|
|
340
|
+
flex-shrink: 0;
|
|
341
|
+
color: #64748b;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.forge-command-palette__text {
|
|
345
|
+
display: flex;
|
|
346
|
+
flex-direction: column;
|
|
347
|
+
gap: 1px;
|
|
348
|
+
min-width: 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.forge-command-palette__label {
|
|
352
|
+
font-size: 0.875rem;
|
|
353
|
+
font-weight: 500;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.forge-command-palette__description {
|
|
357
|
+
font-size: 0.775rem;
|
|
358
|
+
color: #94a3b8;
|
|
359
|
+
white-space: nowrap;
|
|
360
|
+
overflow: hidden;
|
|
361
|
+
text-overflow: ellipsis;
|
|
362
|
+
}
|
|
363
|
+
</style>
|