@codori/client 0.0.6 → 0.0.8
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/app/assets/css/main.css +22 -0
- package/app/components/BottomDrawerShell.vue +62 -0
- package/app/components/ChatWorkspace.vue +704 -110
- package/app/components/PendingUserRequestDrawer.vue +88 -0
- package/app/components/ProjectStatusDot.vue +1 -2
- package/app/components/ReviewStartDrawer.vue +186 -0
- package/app/components/message-part/Event.vue +42 -8
- package/app/components/message-part/ReviewPriorityBadge.vue +38 -0
- package/app/components/message-part/Text.vue +128 -2
- package/app/components/pending-request/McpElicitationForm.vue +264 -0
- package/app/components/pending-request/McpElicitationUrlPrompt.vue +100 -0
- package/app/components/pending-request/RequestUserInputForm.vue +235 -0
- package/app/composables/useChatSession.ts +1 -0
- package/app/composables/usePendingUserRequest.ts +124 -0
- package/app/composables/useThreadSummaries.ts +28 -2
- package/app/pages/index.vue +0 -1
- package/app/utils/chat-turn-engagement.ts +24 -4
- package/app/utils/review-priority-badge.ts +90 -0
- package/package.json +8 -1
- package/server/api/codori/projects/[projectId]/git/branches.get.ts +15 -0
- package/shared/codex-chat.ts +72 -2
- package/shared/codex-rpc.ts +79 -3
- package/shared/codori.ts +20 -0
- package/shared/pending-user-request.ts +374 -0
- package/shared/slash-commands.ts +85 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import type { CodexRpcServerRequest } from './codex-rpc'
|
|
2
|
+
|
|
3
|
+
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
4
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
5
|
+
|
|
6
|
+
const asTrimmedString = (value: unknown) => {
|
|
7
|
+
if (typeof value !== 'string') {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const trimmed = value.trim()
|
|
12
|
+
return trimmed.length > 0 ? trimmed : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const asBoolean = (value: unknown) => value === true
|
|
16
|
+
|
|
17
|
+
const asStringArray = (value: unknown) =>
|
|
18
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
|
19
|
+
|
|
20
|
+
export type PendingRequestUserInputOption = {
|
|
21
|
+
label: string
|
|
22
|
+
description: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type PendingRequestUserInputQuestion = {
|
|
26
|
+
header: string | null
|
|
27
|
+
id: string
|
|
28
|
+
question: string
|
|
29
|
+
options: PendingRequestUserInputOption[]
|
|
30
|
+
isOther: boolean
|
|
31
|
+
isSecret: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PendingRequestUserInput = {
|
|
35
|
+
kind: 'requestUserInput'
|
|
36
|
+
requestId: number
|
|
37
|
+
threadId: string | null
|
|
38
|
+
turnId: string | null
|
|
39
|
+
itemId: string | null
|
|
40
|
+
questions: PendingRequestUserInputQuestion[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type BaseElicitationField = {
|
|
44
|
+
key: string
|
|
45
|
+
label: string
|
|
46
|
+
description: string | null
|
|
47
|
+
required: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type PendingElicitationStringField = BaseElicitationField & {
|
|
51
|
+
kind: 'string'
|
|
52
|
+
minLength: number | null
|
|
53
|
+
maxLength: number | null
|
|
54
|
+
format: 'email' | 'uri' | 'date' | 'date-time' | null
|
|
55
|
+
defaultValue: string | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PendingElicitationNumberField = BaseElicitationField & {
|
|
59
|
+
kind: 'number'
|
|
60
|
+
numericType: 'number' | 'integer'
|
|
61
|
+
minimum: number | null
|
|
62
|
+
maximum: number | null
|
|
63
|
+
defaultValue: number | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type PendingElicitationBooleanField = BaseElicitationField & {
|
|
67
|
+
kind: 'boolean'
|
|
68
|
+
defaultValue: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type PendingElicitationEnumField = BaseElicitationField & {
|
|
72
|
+
kind: 'enum'
|
|
73
|
+
valueType: 'string' | 'number' | 'integer' | 'boolean'
|
|
74
|
+
options: Array<{
|
|
75
|
+
label: string
|
|
76
|
+
value: string | number | boolean
|
|
77
|
+
}>
|
|
78
|
+
defaultValue: string | number | boolean | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type PendingElicitationField =
|
|
82
|
+
| PendingElicitationStringField
|
|
83
|
+
| PendingElicitationNumberField
|
|
84
|
+
| PendingElicitationBooleanField
|
|
85
|
+
| PendingElicitationEnumField
|
|
86
|
+
|
|
87
|
+
export type PendingMcpElicitationForm = {
|
|
88
|
+
kind: 'mcpElicitationForm'
|
|
89
|
+
requestId: number
|
|
90
|
+
threadId: string | null
|
|
91
|
+
message: string | null
|
|
92
|
+
fields: PendingElicitationField[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type PendingMcpElicitationUrl = {
|
|
96
|
+
kind: 'mcpElicitationUrl'
|
|
97
|
+
requestId: number
|
|
98
|
+
threadId: string | null
|
|
99
|
+
message: string | null
|
|
100
|
+
url: string
|
|
101
|
+
elicitationId: string | null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type PendingUserRequest =
|
|
105
|
+
| PendingRequestUserInput
|
|
106
|
+
| PendingMcpElicitationForm
|
|
107
|
+
| PendingMcpElicitationUrl
|
|
108
|
+
|
|
109
|
+
const parseRequestUserInputOption = (value: unknown): PendingRequestUserInputOption | null => {
|
|
110
|
+
if (!isObjectRecord(value)) {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const label = asTrimmedString(value.label)
|
|
115
|
+
if (!label) {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
label,
|
|
121
|
+
description: asTrimmedString(value.description)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const parseRequestUserInputQuestion = (value: unknown): PendingRequestUserInputQuestion | null => {
|
|
126
|
+
if (!isObjectRecord(value)) {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const id = asTrimmedString(value.id)
|
|
131
|
+
const question = asTrimmedString(value.question)
|
|
132
|
+
if (!id || !question) {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const options = Array.isArray(value.options)
|
|
137
|
+
? value.options
|
|
138
|
+
.map(parseRequestUserInputOption)
|
|
139
|
+
.filter((option): option is PendingRequestUserInputOption => option !== null)
|
|
140
|
+
: []
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
header: asTrimmedString(value.header),
|
|
144
|
+
id,
|
|
145
|
+
question,
|
|
146
|
+
options,
|
|
147
|
+
isOther: asBoolean(value.isOther),
|
|
148
|
+
isSecret: asBoolean(value.isSecret)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const parseNumber = (value: unknown) =>
|
|
153
|
+
typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
154
|
+
|
|
155
|
+
const parseEnumValueType = (values: Array<string | number | boolean>) => {
|
|
156
|
+
if (values.every(value => typeof value === 'string')) {
|
|
157
|
+
return 'string'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (values.every(value => typeof value === 'boolean')) {
|
|
161
|
+
return 'boolean'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (values.every(value => typeof value === 'number' && Number.isInteger(value))) {
|
|
165
|
+
return 'integer'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (values.every(value => typeof value === 'number')) {
|
|
169
|
+
return 'number'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const parseElicitationField = (
|
|
176
|
+
key: string,
|
|
177
|
+
value: unknown,
|
|
178
|
+
requiredKeys: Set<string>
|
|
179
|
+
): PendingElicitationField | null => {
|
|
180
|
+
if (!isObjectRecord(value)) {
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const label = asTrimmedString(value.title) ?? key
|
|
185
|
+
const description = asTrimmedString(value.description)
|
|
186
|
+
const required = requiredKeys.has(key)
|
|
187
|
+
const enumValues = Array.isArray(value.enum)
|
|
188
|
+
? value.enum.filter((entry): entry is string | number | boolean =>
|
|
189
|
+
typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean')
|
|
190
|
+
: []
|
|
191
|
+
|
|
192
|
+
if (enumValues.length > 0) {
|
|
193
|
+
const valueType = parseEnumValueType(enumValues)
|
|
194
|
+
if (!valueType) {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const defaultValue = enumValues.includes(value.default as never)
|
|
199
|
+
? value.default as string | number | boolean
|
|
200
|
+
: null
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
kind: 'enum',
|
|
204
|
+
key,
|
|
205
|
+
label,
|
|
206
|
+
description,
|
|
207
|
+
required,
|
|
208
|
+
valueType,
|
|
209
|
+
defaultValue,
|
|
210
|
+
options: enumValues.map(option => ({
|
|
211
|
+
label: String(option),
|
|
212
|
+
value: option
|
|
213
|
+
}))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (value.type === 'string') {
|
|
218
|
+
const format = value.format === 'email'
|
|
219
|
+
|| value.format === 'uri'
|
|
220
|
+
|| value.format === 'date'
|
|
221
|
+
|| value.format === 'date-time'
|
|
222
|
+
? value.format
|
|
223
|
+
: null
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
kind: 'string',
|
|
227
|
+
key,
|
|
228
|
+
label,
|
|
229
|
+
description,
|
|
230
|
+
required,
|
|
231
|
+
minLength: parseNumber(value.minLength),
|
|
232
|
+
maxLength: parseNumber(value.maxLength),
|
|
233
|
+
format,
|
|
234
|
+
defaultValue: typeof value.default === 'string' ? value.default : null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (value.type === 'number' || value.type === 'integer') {
|
|
239
|
+
return {
|
|
240
|
+
kind: 'number',
|
|
241
|
+
key,
|
|
242
|
+
label,
|
|
243
|
+
description,
|
|
244
|
+
required,
|
|
245
|
+
numericType: value.type,
|
|
246
|
+
minimum: parseNumber(value.minimum),
|
|
247
|
+
maximum: parseNumber(value.maximum),
|
|
248
|
+
defaultValue: typeof value.default === 'number' ? value.default : null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (value.type === 'boolean') {
|
|
253
|
+
return {
|
|
254
|
+
kind: 'boolean',
|
|
255
|
+
key,
|
|
256
|
+
label,
|
|
257
|
+
description,
|
|
258
|
+
required,
|
|
259
|
+
defaultValue: value.default === true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const parsePendingUserRequest = (request: CodexRpcServerRequest): PendingUserRequest | null => {
|
|
267
|
+
const params = isObjectRecord(request.params) ? request.params : null
|
|
268
|
+
|
|
269
|
+
switch (request.method) {
|
|
270
|
+
case 'item/tool/requestUserInput': {
|
|
271
|
+
const questions = Array.isArray(params?.questions)
|
|
272
|
+
? params.questions
|
|
273
|
+
.map(parseRequestUserInputQuestion)
|
|
274
|
+
.filter((question): question is PendingRequestUserInputQuestion => question !== null)
|
|
275
|
+
: []
|
|
276
|
+
if (questions.length === 0) {
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
kind: 'requestUserInput',
|
|
282
|
+
requestId: request.id,
|
|
283
|
+
threadId: asTrimmedString(params?.threadId),
|
|
284
|
+
turnId: asTrimmedString(params?.turnId),
|
|
285
|
+
itemId: asTrimmedString(params?.itemId),
|
|
286
|
+
questions
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
case 'mcpServer/elicitation/request': {
|
|
290
|
+
const mode = asTrimmedString(params?.mode)
|
|
291
|
+
if (mode === 'url') {
|
|
292
|
+
const url = asTrimmedString(params?.url)
|
|
293
|
+
if (!url) {
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
kind: 'mcpElicitationUrl',
|
|
299
|
+
requestId: request.id,
|
|
300
|
+
threadId: asTrimmedString(params?.threadId),
|
|
301
|
+
message: asTrimmedString(params?.message),
|
|
302
|
+
url,
|
|
303
|
+
elicitationId: asTrimmedString(params?.elicitationId)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (mode !== 'form') {
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const requestedSchema = isObjectRecord(params?.requestedSchema) ? params.requestedSchema : null
|
|
312
|
+
if (!requestedSchema || requestedSchema.type !== 'object') {
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const properties = isObjectRecord(requestedSchema.properties) ? requestedSchema.properties : null
|
|
317
|
+
if (!properties) {
|
|
318
|
+
return null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const requiredKeys = new Set(asStringArray(requestedSchema.required))
|
|
322
|
+
const fields = Object.entries(properties)
|
|
323
|
+
.map(([key, value]) => parseElicitationField(key, value, requiredKeys))
|
|
324
|
+
.filter((field): field is PendingElicitationField => field !== null)
|
|
325
|
+
if (fields.length === 0) {
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
kind: 'mcpElicitationForm',
|
|
331
|
+
requestId: request.id,
|
|
332
|
+
threadId: asTrimmedString(params?.threadId),
|
|
333
|
+
message: asTrimmedString(params?.message),
|
|
334
|
+
fields
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
default:
|
|
338
|
+
return null
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const sanitizeAnswerList = (answers: string[]) =>
|
|
343
|
+
answers
|
|
344
|
+
.map(answer => answer.trim())
|
|
345
|
+
.filter(answer => answer.length > 0)
|
|
346
|
+
|
|
347
|
+
export const buildRequestUserInputResponse = (
|
|
348
|
+
answers: Record<string, string[]>
|
|
349
|
+
) => ({
|
|
350
|
+
answers: Object.fromEntries(
|
|
351
|
+
Object.entries(answers).map(([questionId, questionAnswers]) => [questionId, sanitizeAnswerList(questionAnswers)])
|
|
352
|
+
)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
export const buildMcpElicitationResponse = (
|
|
356
|
+
action: 'accept' | 'decline' | 'cancel',
|
|
357
|
+
content?: Record<string, string | number | boolean>
|
|
358
|
+
) => {
|
|
359
|
+
if (action !== 'accept') {
|
|
360
|
+
return { action }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return content ? { action, content } : { action }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export const buildPendingUserRequestDismissResponse = (request: PendingUserRequest) => {
|
|
367
|
+
switch (request.kind) {
|
|
368
|
+
case 'requestUserInput':
|
|
369
|
+
return buildRequestUserInputResponse({})
|
|
370
|
+
case 'mcpElicitationForm':
|
|
371
|
+
case 'mcpElicitationUrl':
|
|
372
|
+
return buildMcpElicitationResponse('cancel')
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type SlashCommandName = 'review'
|
|
2
|
+
|
|
3
|
+
export type SlashCommandDefinition = {
|
|
4
|
+
name: SlashCommandName
|
|
5
|
+
description: string
|
|
6
|
+
supportsInlineArgs: boolean
|
|
7
|
+
completeOnSpace: boolean
|
|
8
|
+
executeOnEnter: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ActiveSlashCommandMatch = {
|
|
12
|
+
start: number
|
|
13
|
+
end: number
|
|
14
|
+
raw: string
|
|
15
|
+
query: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SubmittedSlashCommand = {
|
|
19
|
+
name: string
|
|
20
|
+
args: string
|
|
21
|
+
isBare: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const SLASH_COMMANDS: SlashCommandDefinition[] = [{
|
|
25
|
+
name: 'review',
|
|
26
|
+
description: 'Review current changes or compare against a base branch.',
|
|
27
|
+
supportsInlineArgs: false,
|
|
28
|
+
completeOnSpace: true,
|
|
29
|
+
executeOnEnter: true
|
|
30
|
+
}]
|
|
31
|
+
|
|
32
|
+
export const findActiveSlashCommand = (
|
|
33
|
+
input: string,
|
|
34
|
+
selectionStart: number | null | undefined,
|
|
35
|
+
selectionEnd: number | null | undefined
|
|
36
|
+
): ActiveSlashCommandMatch | null => {
|
|
37
|
+
if (selectionStart == null || selectionEnd == null || selectionStart !== selectionEnd) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const caret = selectionStart
|
|
42
|
+
const lineStart = input.lastIndexOf('\n', Math.max(0, caret - 1)) + 1
|
|
43
|
+
const linePrefix = input.slice(lineStart, caret)
|
|
44
|
+
const match = /(?:^|\s)(\/[a-z-]*)$/i.exec(linePrefix)
|
|
45
|
+
if (!match) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const raw = match[1] ?? ''
|
|
50
|
+
if (!raw.startsWith('/')) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
start: caret - raw.length,
|
|
56
|
+
end: caret,
|
|
57
|
+
raw,
|
|
58
|
+
query: raw.slice(1).toLowerCase()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const filterSlashCommands = (
|
|
63
|
+
commands: SlashCommandDefinition[],
|
|
64
|
+
query: string
|
|
65
|
+
) => {
|
|
66
|
+
const normalizedQuery = query.trim().toLowerCase()
|
|
67
|
+
return commands.filter(command => command.name.startsWith(normalizedQuery))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const parseSubmittedSlashCommand = (input: string): SubmittedSlashCommand | null => {
|
|
71
|
+
const trimmed = input.trim()
|
|
72
|
+
const match = /^\/([a-z-]+)(?:\s+(.*))?$/i.exec(trimmed)
|
|
73
|
+
if (!match) {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
name: match[1]?.toLowerCase() ?? '',
|
|
79
|
+
args: match[2]?.trim() ?? '',
|
|
80
|
+
isBare: !(match[2]?.trim())
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const toSlashCommandCompletion = (command: Pick<SlashCommandDefinition, 'name'>) =>
|
|
85
|
+
`/${command.name} `
|