@codori/client 0.0.2 → 0.0.4

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.
@@ -2,15 +2,27 @@
2
2
  import { useRouter } from '#imports'
3
3
  import { computed, nextTick, onMounted, ref, watch } from 'vue'
4
4
  import MessageContent from './MessageContent.vue'
5
- import { useChatSession, type SubagentPanelState } from '../composables/useChatSession'
5
+ import {
6
+ reconcileOptimisticUserMessage,
7
+ removeChatMessage,
8
+ removePendingUserMessageId,
9
+ resolvePromptSubmitStatus,
10
+ resolveTurnSubmissionMethod,
11
+ shouldIgnoreNotificationAfterInterrupt
12
+ } from '../utils/chat-turn-engagement'
13
+ import { useChatAttachments, type DraftAttachment } from '../composables/useChatAttachments'
14
+ import { useChatSession, type LiveStream, type SubagentPanelState } from '../composables/useChatSession'
6
15
  import { useProjects } from '../composables/useProjects'
7
16
  import { useRpc } from '../composables/useRpc'
8
17
  import { useChatSubmitGuard } from '../composables/useChatSubmitGuard'
9
18
  import {
19
+ hideThinkingPlaceholder,
10
20
  ITEM_PART,
11
21
  eventToMessage,
12
22
  isSubagentActiveStatus,
13
23
  itemToMessages,
24
+ replaceStreamingMessage,
25
+ showThinkingPlaceholder,
14
26
  threadToMessages,
15
27
  upsertStreamingMessage,
16
28
  type ChatMessage,
@@ -19,7 +31,10 @@ import {
19
31
  type ItemData,
20
32
  type McpToolCallItem
21
33
  } from '~~/shared/codex-chat'
34
+ import { buildTurnStartInput } from '~~/shared/chat-attachments'
22
35
  import {
36
+ type ConfigReadResponse,
37
+ type ModelListResponse,
23
38
  notificationThreadId,
24
39
  notificationTurnId,
25
40
  type CodexRpcNotification,
@@ -30,6 +45,21 @@ import {
30
45
  type ThreadStartResponse,
31
46
  type TurnStartResponse
32
47
  } from '~~/shared/codex-rpc'
48
+ import {
49
+ buildTurnOverrides,
50
+ coercePromptSelection,
51
+ ensureModelOption,
52
+ FALLBACK_MODELS,
53
+ formatCompactTokenCount,
54
+ formatReasoningEffortLabel,
55
+ normalizeConfigDefaults,
56
+ normalizeModelList,
57
+ normalizeThreadTokenUsage,
58
+ resolveContextWindowState,
59
+ resolveEffortOptions,
60
+ visibleModelOptions,
61
+ type ReasoningEffort
62
+ } from '~~/shared/chat-prompt-controls'
33
63
  import { toProjectThreadRoute } from '~~/shared/codori'
34
64
 
35
65
  const props = defineProps<{
@@ -50,6 +80,25 @@ const {
50
80
  onCompositionEnd,
51
81
  shouldSubmit
52
82
  } = useChatSubmitGuard()
83
+ const {
84
+ attachments,
85
+ isDragging,
86
+ isUploading,
87
+ error: attachmentError,
88
+ fileInput,
89
+ removeAttachment,
90
+ replaceAttachments,
91
+ clearAttachments,
92
+ discardSnapshot,
93
+ openFilePicker,
94
+ onFileInputChange,
95
+ onPaste,
96
+ onDragEnter,
97
+ onDragLeave,
98
+ onDragOver,
99
+ onDrop,
100
+ uploadAttachments
101
+ } = useChatAttachments(props.projectId)
53
102
 
54
103
  const input = ref('')
55
104
  const scrollViewport = ref<HTMLElement | null>(null)
@@ -64,12 +113,39 @@ const {
64
113
  threadTitle,
65
114
  pendingThreadId,
66
115
  autoRedirectThreadId,
67
- loadVersion
116
+ loadVersion,
117
+ promptControlsLoaded,
118
+ promptControlsLoading,
119
+ availableModels,
120
+ selectedModel,
121
+ selectedEffort,
122
+ modelContextWindow,
123
+ tokenUsage
68
124
  } = session
69
125
 
70
126
  const selectedProject = computed(() => getProject(props.projectId))
71
- const submitError = computed(() => error.value ? new Error(error.value) : undefined)
72
- const isBusy = computed(() => status.value === 'submitted' || status.value === 'streaming')
127
+ const composerError = computed(() => attachmentError.value ?? error.value)
128
+ const submitError = computed(() => composerError.value ? new Error(composerError.value) : undefined)
129
+ const interruptRequested = ref(false)
130
+ const isBusy = computed(() =>
131
+ status.value === 'submitted'
132
+ || status.value === 'streaming'
133
+ || isUploading.value
134
+ )
135
+ const hasDraftContent = computed(() =>
136
+ input.value.trim().length > 0
137
+ || attachments.value.length > 0
138
+ )
139
+ const isComposerDisabled = computed(() =>
140
+ isUploading.value
141
+ || interruptRequested.value
142
+ )
143
+ const promptSubmitStatus = computed(() =>
144
+ resolvePromptSubmitStatus({
145
+ status: status.value,
146
+ hasDraftContent: hasDraftContent.value
147
+ })
148
+ )
73
149
  const routeThreadId = computed(() => props.threadId ?? null)
74
150
  const projectTitle = computed(() => selectedProject.value?.projectId ?? props.projectId)
75
151
  const showWelcomeState = computed(() =>
@@ -98,7 +174,134 @@ const starterPrompts = computed(() => {
98
174
  ]
99
175
  })
100
176
 
177
+ const hasKnownThreadUsage = computed(() => !activeThreadId.value || tokenUsage.value !== null)
178
+ const effectiveModelList = computed(() => {
179
+ const withSelected = ensureModelOption(
180
+ availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS,
181
+ selectedModel.value,
182
+ selectedEffort.value
183
+ )
184
+ return visibleModelOptions(withSelected)
185
+ })
186
+ const selectedModelOption = computed(() =>
187
+ effectiveModelList.value.find(model => model.model === selectedModel.value)
188
+ ?? effectiveModelList.value[0]
189
+ ?? FALLBACK_MODELS[0]
190
+ )
191
+ const modelSelectItems = computed(() =>
192
+ effectiveModelList.value.map(model => ({
193
+ label: model.displayName,
194
+ value: model.model
195
+ }))
196
+ )
197
+ const effortOptions = computed(() => resolveEffortOptions(effectiveModelList.value, selectedModel.value))
198
+ const effortSelectItems = computed(() =>
199
+ effortOptions.value.map(effort => ({
200
+ label: formatReasoningEffortLabel(effort),
201
+ value: effort
202
+ }))
203
+ )
204
+ const contextWindowState = computed(() =>
205
+ resolveContextWindowState(tokenUsage.value, modelContextWindow.value, hasKnownThreadUsage.value)
206
+ )
207
+ const contextUsedPercent = computed(() => contextWindowState.value.usedPercent ?? 0)
208
+ const contextIndicatorLabel = computed(() => {
209
+ const remainingPercent = contextWindowState.value.remainingPercent
210
+ return remainingPercent == null ? 'ctx' : `${Math.round(remainingPercent)}%`
211
+ })
212
+
213
+ const normalizePromptSelection = (
214
+ preferredModel?: string | null,
215
+ preferredEffort?: ReasoningEffort | null
216
+ ) => {
217
+ const withSelected = ensureModelOption(
218
+ availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS,
219
+ preferredModel ?? selectedModel.value,
220
+ preferredEffort ?? selectedEffort.value
221
+ )
222
+ const visibleModels = visibleModelOptions(withSelected)
223
+ const nextSelection = coercePromptSelection(visibleModels, preferredModel ?? selectedModel.value, preferredEffort ?? selectedEffort.value)
224
+
225
+ availableModels.value = visibleModels
226
+ if (selectedModel.value !== nextSelection.model) {
227
+ selectedModel.value = nextSelection.model
228
+ }
229
+ if (selectedEffort.value !== nextSelection.effort) {
230
+ selectedEffort.value = nextSelection.effort
231
+ }
232
+ }
233
+
234
+ const syncPromptSelectionFromThread = (
235
+ model: string | null | undefined,
236
+ effort: ReasoningEffort | null | undefined
237
+ ) => {
238
+ normalizePromptSelection(model ?? null, effort ?? null)
239
+ }
240
+
241
+ const loadPromptControls = async () => {
242
+ if (promptControlsLoaded.value) {
243
+ return
244
+ }
245
+
246
+ if (promptControlsPromise) {
247
+ return await promptControlsPromise
248
+ }
249
+
250
+ promptControlsPromise = (async () => {
251
+ promptControlsLoading.value = true
252
+
253
+ try {
254
+ await ensureProjectRuntime()
255
+ const client = getClient(props.projectId)
256
+ const initialModel = selectedModel.value
257
+ const initialEffort = selectedEffort.value
258
+ let nextModels = availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS
259
+ let defaultModel: string | null = null
260
+ let defaultEffort: ReasoningEffort | null = null
261
+
262
+ const [modelsResponse, configResponse] = await Promise.allSettled([
263
+ client.request<ModelListResponse>('model/list'),
264
+ client.request<ConfigReadResponse>('config/read')
265
+ ])
266
+
267
+ if (modelsResponse.status === 'fulfilled') {
268
+ nextModels = visibleModelOptions(normalizeModelList(modelsResponse.value))
269
+ } else {
270
+ nextModels = visibleModelOptions(nextModels)
271
+ }
272
+
273
+ if (configResponse.status === 'fulfilled') {
274
+ const defaults = normalizeConfigDefaults(configResponse.value)
275
+ if (defaults.contextWindow != null) {
276
+ modelContextWindow.value = defaults.contextWindow
277
+ }
278
+ defaultModel = defaults.model
279
+ defaultEffort = defaults.effort
280
+ }
281
+
282
+ availableModels.value = nextModels
283
+
284
+ const preferredModel = selectedModel.value !== initialModel
285
+ ? selectedModel.value
286
+ : defaultModel ?? initialModel
287
+ const preferredEffort = selectedEffort.value !== initialEffort
288
+ ? selectedEffort.value
289
+ : defaultEffort ?? initialEffort
290
+
291
+ normalizePromptSelection(preferredModel, preferredEffort)
292
+ promptControlsLoaded.value = true
293
+ } finally {
294
+ promptControlsLoading.value = false
295
+ promptControlsPromise = null
296
+ }
297
+ })()
298
+
299
+ return await promptControlsPromise
300
+ }
301
+
101
302
  const subagentBootstrapPromises = new Map<string, Promise<void>>()
303
+ const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
304
+ let promptControlsPromise: Promise<void> | null = null
102
305
 
103
306
  const isActiveTurnStatus = (value: string | null | undefined) => {
104
307
  if (!value) {
@@ -113,13 +316,263 @@ const currentLiveStream = () =>
113
316
  ? session.liveStream
114
317
  : null
115
318
 
116
- const clearLiveStream = () => {
117
- session.liveStream?.unsubscribe?.()
118
- session.liveStream = null
319
+ const hasActiveTurnEngagement = () =>
320
+ Boolean(currentLiveStream() || session.pendingLiveStream)
321
+
322
+ const rejectLiveStreamTurnWaiters = (liveStream: LiveStream, error: Error) => {
323
+ const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
324
+ for (const waiter of waiters) {
325
+ waiter.reject(error)
326
+ }
327
+ }
328
+
329
+ const createLiveStreamState = (
330
+ threadId: string,
331
+ bufferedNotifications: CodexRpcNotification[] = []
332
+ ): LiveStream => ({
333
+ threadId,
334
+ turnId: null,
335
+ bufferedNotifications,
336
+ observedSubagentThreadIds: new Set<string>(),
337
+ pendingUserMessageIds: [],
338
+ turnIdWaiters: [],
339
+ interruptRequested: false,
340
+ interruptAcknowledged: false,
341
+ unsubscribe: null
342
+ })
343
+
344
+ const setSessionLiveStream = (liveStream: LiveStream | null) => {
345
+ session.liveStream = liveStream
346
+ interruptRequested.value = liveStream?.interruptRequested === true
119
347
  }
120
348
 
121
- const stripOptimisticDraftMessages = () => {
122
- messages.value = messages.value.filter(message => !message.id.startsWith('local-user-'))
349
+ const setLiveStreamInterruptRequested = (liveStream: LiveStream, nextValue: boolean) => {
350
+ liveStream.interruptRequested = nextValue
351
+
352
+ if (session.liveStream === liveStream) {
353
+ interruptRequested.value = nextValue
354
+ }
355
+ }
356
+
357
+ const setLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
358
+ liveStream.turnId = turnId
359
+
360
+ if (!turnId) {
361
+ return
362
+ }
363
+
364
+ liveStream.interruptAcknowledged = false
365
+ const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
366
+ for (const waiter of waiters) {
367
+ waiter.resolve(turnId)
368
+ }
369
+ }
370
+
371
+ const waitForLiveStreamTurnId = async (liveStream: LiveStream) => {
372
+ if (liveStream.turnId) {
373
+ return liveStream.turnId
374
+ }
375
+
376
+ return await new Promise<string>((resolve, reject) => {
377
+ liveStream.turnIdWaiters.push({ resolve, reject })
378
+ })
379
+ }
380
+
381
+ const clearLiveStream = (reason?: Error) => {
382
+ const liveStream = session.liveStream
383
+ if (!liveStream) {
384
+ interruptRequested.value = false
385
+ return null
386
+ }
387
+
388
+ liveStream.unsubscribe?.()
389
+ rejectLiveStreamTurnWaiters(liveStream, reason ?? new Error('The active turn is no longer available.'))
390
+ setSessionLiveStream(null)
391
+ return liveStream
392
+ }
393
+
394
+ const ensurePendingLiveStream = async () => {
395
+ const existingLiveStream = currentLiveStream()
396
+ if (existingLiveStream) {
397
+ return existingLiveStream
398
+ }
399
+
400
+ if (session.pendingLiveStream) {
401
+ return await session.pendingLiveStream
402
+ }
403
+
404
+ const pendingLiveStream = (async () => {
405
+ await ensureProjectRuntime()
406
+ const { threadId, created } = await ensureThread()
407
+ const client = getClient(props.projectId)
408
+ const buffered: CodexRpcNotification[] = []
409
+ clearLiveStream()
410
+
411
+ const liveStream = createLiveStreamState(threadId, buffered)
412
+ liveStream.unsubscribe = client.subscribe((notification) => {
413
+ const targetThreadId = notificationThreadId(notification)
414
+ if (!targetThreadId) {
415
+ return
416
+ }
417
+
418
+ if (targetThreadId !== threadId) {
419
+ if (liveStream.observedSubagentThreadIds.has(targetThreadId)) {
420
+ applySubagentNotification(targetThreadId, notification)
421
+ }
422
+ return
423
+ }
424
+
425
+ if (!liveStream.turnId) {
426
+ buffered.push(notification)
427
+ return
428
+ }
429
+
430
+ const turnId = notificationTurnId(notification)
431
+ if (turnId && turnId !== liveStream.turnId) {
432
+ return
433
+ }
434
+
435
+ applyNotification(notification)
436
+ })
437
+
438
+ setSessionLiveStream(liveStream)
439
+
440
+ if (created && !routeThreadId.value) {
441
+ pendingThreadId.value = threadId
442
+ }
443
+
444
+ return liveStream
445
+ })()
446
+
447
+ session.pendingLiveStream = pendingLiveStream
448
+
449
+ try {
450
+ return await pendingLiveStream
451
+ } finally {
452
+ if (session.pendingLiveStream === pendingLiveStream) {
453
+ session.pendingLiveStream = null
454
+ }
455
+ }
456
+ }
457
+
458
+ const createOptimisticMessageId = () =>
459
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
460
+ ? `local-user-${crypto.randomUUID()}`
461
+ : `local-user-${Date.now()}-${Math.random().toString(16).slice(2)}`
462
+
463
+ const buildOptimisticMessage = (text: string, submittedAttachments: DraftAttachment[]): ChatMessage => ({
464
+ id: createOptimisticMessageId(),
465
+ role: 'user',
466
+ parts: [
467
+ ...(text.trim()
468
+ ? [{
469
+ type: 'text' as const,
470
+ text,
471
+ state: 'done' as const
472
+ }]
473
+ : []),
474
+ ...submittedAttachments.map((attachment) => ({
475
+ type: 'attachment' as const,
476
+ attachment: {
477
+ kind: 'image' as const,
478
+ name: attachment.name,
479
+ mediaType: attachment.mediaType,
480
+ url: attachment.previewUrl
481
+ }
482
+ }))
483
+ ]
484
+ })
485
+
486
+ const rememberOptimisticAttachments = (messageId: string, submittedAttachments: DraftAttachment[]) => {
487
+ if (submittedAttachments.length === 0) {
488
+ return
489
+ }
490
+
491
+ optimisticAttachmentSnapshots.set(messageId, submittedAttachments)
492
+ }
493
+
494
+ const formatAttachmentSize = (size: number) => {
495
+ if (size >= 1024 * 1024) {
496
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`
497
+ }
498
+
499
+ if (size >= 1024) {
500
+ return `${Math.round(size / 1024)} KB`
501
+ }
502
+
503
+ return `${size} B`
504
+ }
505
+
506
+ const removeOptimisticMessage = (messageId: string) => {
507
+ messages.value = removeChatMessage(messages.value, messageId)
508
+ optimisticAttachmentSnapshots.delete(messageId)
509
+ }
510
+
511
+ const clearThinkingPlaceholder = () => {
512
+ messages.value = hideThinkingPlaceholder(messages.value)
513
+ }
514
+
515
+ const ensureThinkingPlaceholder = () => {
516
+ messages.value = showThinkingPlaceholder(messages.value)
517
+ }
518
+
519
+ const clearThinkingPlaceholderForVisibleItem = (item: CodexThreadItem) => {
520
+ if (item.type !== 'userMessage') {
521
+ clearThinkingPlaceholder()
522
+ }
523
+ }
524
+
525
+ const restoreDraftIfPristine = (text: string, submittedAttachments: DraftAttachment[]) => {
526
+ if (!input.value.trim()) {
527
+ input.value = text
528
+ }
529
+
530
+ if (attachments.value.length === 0 && submittedAttachments.length > 0) {
531
+ replaceAttachments(submittedAttachments)
532
+ }
533
+ }
534
+
535
+ const untrackPendingUserMessage = (messageId: string) => {
536
+ const liveStream = currentLiveStream()
537
+ if (!liveStream) {
538
+ return
539
+ }
540
+
541
+ liveStream.pendingUserMessageIds = removePendingUserMessageId(liveStream.pendingUserMessageIds, messageId)
542
+ }
543
+
544
+ const reconcilePendingUserMessage = (confirmedMessage: ChatMessage) => {
545
+ const liveStream = currentLiveStream()
546
+ const optimisticMessageId = liveStream?.pendingUserMessageIds.shift() ?? null
547
+ if (!optimisticMessageId) {
548
+ messages.value = upsertStreamingMessage(messages.value, confirmedMessage)
549
+ return
550
+ }
551
+
552
+ const pendingAttachments = optimisticAttachmentSnapshots.get(optimisticMessageId)
553
+ if (pendingAttachments) {
554
+ discardSnapshot(pendingAttachments)
555
+ optimisticAttachmentSnapshots.delete(optimisticMessageId)
556
+ }
557
+
558
+ messages.value = reconcileOptimisticUserMessage(messages.value, optimisticMessageId, confirmedMessage)
559
+ }
560
+
561
+ const clearPendingOptimisticMessages = (liveStream: LiveStream | null, options?: { discardSnapshots?: boolean }) => {
562
+ if (!liveStream) {
563
+ return
564
+ }
565
+
566
+ for (const messageId of liveStream.pendingUserMessageIds) {
567
+ messages.value = removeChatMessage(messages.value, messageId)
568
+ const pendingAttachments = optimisticAttachmentSnapshots.get(messageId)
569
+ if (options?.discardSnapshots && pendingAttachments) {
570
+ discardSnapshot(pendingAttachments)
571
+ }
572
+ optimisticAttachmentSnapshots.delete(messageId)
573
+ }
574
+
575
+ liveStream.pendingUserMessageIds = []
123
576
  }
124
577
 
125
578
  const resolveThreadTitle = (thread: { name: string | null, preview: string, id: string }) => {
@@ -249,22 +702,21 @@ const hydrateThread = async (threadId: string) => {
249
702
  const requestVersion = loadVersion.value + 1
250
703
  loadVersion.value = requestVersion
251
704
  error.value = null
705
+ tokenUsage.value = null
252
706
 
253
707
  try {
254
708
  await ensureProjectRuntime()
255
709
  const client = getClient(props.projectId)
256
710
  activeThreadId.value = threadId
257
711
 
258
- const existingLiveStream = currentLiveStream()
712
+ const existingLiveStream = session.liveStream
259
713
 
260
- if (!existingLiveStream) {
261
- const nextLiveStream = {
262
- threadId,
263
- turnId: null,
264
- bufferedNotifications: [] as CodexRpcNotification[],
265
- observedSubagentThreadIds: new Set<string>(),
266
- unsubscribe: null as (() => void) | null
267
- }
714
+ if (existingLiveStream && existingLiveStream.threadId !== threadId) {
715
+ clearLiveStream()
716
+ }
717
+
718
+ if (!session.liveStream) {
719
+ const nextLiveStream = createLiveStreamState(threadId)
268
720
 
269
721
  nextLiveStream.unsubscribe = client.subscribe((notification) => {
270
722
  const targetThreadId = notificationThreadId(notification)
@@ -292,10 +744,10 @@ const hydrateThread = async (threadId: string) => {
292
744
  applyNotification(notification)
293
745
  })
294
746
 
295
- session.liveStream = nextLiveStream
747
+ setSessionLiveStream(nextLiveStream)
296
748
  }
297
749
 
298
- await client.request<ThreadResumeResponse>('thread/resume', {
750
+ const resumeResponse = await client.request<ThreadResumeResponse>('thread/resume', {
299
751
  threadId,
300
752
  cwd: selectedProject.value?.projectPath ?? null,
301
753
  approvalPolicy: 'never',
@@ -310,6 +762,10 @@ const hydrateThread = async (threadId: string) => {
310
762
  return
311
763
  }
312
764
 
765
+ syncPromptSelectionFromThread(
766
+ resumeResponse.model ?? null,
767
+ (resumeResponse.reasoningEffort as ReasoningEffort | null | undefined) ?? null
768
+ )
313
769
  activeThreadId.value = response.thread.id
314
770
  threadTitle.value = resolveThreadTitle(response.thread)
315
771
  messages.value = threadToMessages(response.thread)
@@ -327,7 +783,7 @@ const hydrateThread = async (threadId: string) => {
327
783
  return
328
784
  }
329
785
 
330
- session.liveStream.turnId = activeTurn.id
786
+ setLiveStreamTurnId(session.liveStream, activeTurn.id)
331
787
  status.value = 'streaming'
332
788
 
333
789
  const pendingNotifications = session.liveStream.bufferedNotifications.splice(0, session.liveStream.bufferedNotifications.length)
@@ -348,6 +804,7 @@ const hydrateThread = async (threadId: string) => {
348
804
 
349
805
  const resetDraftThread = () => {
350
806
  clearLiveStream()
807
+ session.pendingLiveStream = null
351
808
  activeThreadId.value = null
352
809
  threadTitle.value = null
353
810
  pendingThreadId.value = null
@@ -355,6 +812,7 @@ const resetDraftThread = () => {
355
812
  messages.value = []
356
813
  subagentPanels.value = []
357
814
  error.value = null
815
+ tokenUsage.value = null
358
816
  status.value = 'ready'
359
817
  }
360
818
 
@@ -790,7 +1248,7 @@ const applySubagentNotification = (threadId: string, notification: CodexRpcNotif
790
1248
  }
791
1249
  for (const nextMessage of itemToMessages(params.item)) {
792
1250
  updateSubagentPanelMessages(threadId, (panelMessages) =>
793
- upsertStreamingMessage(panelMessages, {
1251
+ replaceStreamingMessage(panelMessages, {
794
1252
  ...nextMessage,
795
1253
  pending: false
796
1254
  })
@@ -947,6 +1405,11 @@ const applySubagentNotification = (threadId: string, notification: CodexRpcNotif
947
1405
  }
948
1406
 
949
1407
  const applyNotification = (notification: CodexRpcNotification) => {
1408
+ const liveStream = currentLiveStream()
1409
+ if (liveStream?.interruptAcknowledged && shouldIgnoreNotificationAfterInterrupt(notification.method)) {
1410
+ return
1411
+ }
1412
+
950
1413
  switch (notification.method) {
951
1414
  case 'thread/started': {
952
1415
  const nextThreadId = notificationThreadId(notification)
@@ -959,6 +1422,10 @@ const applyNotification = (notification: CodexRpcNotification) => {
959
1422
  return
960
1423
  }
961
1424
  case 'turn/started': {
1425
+ if (liveStream) {
1426
+ setLiveStreamTurnId(liveStream, notificationTurnId(notification))
1427
+ setLiveStreamInterruptRequested(liveStream, false)
1428
+ }
962
1429
  messages.value = upsertStreamingMessage(
963
1430
  messages.value,
964
1431
  eventToMessage(`event-turn-started-${notificationTurnId(notification) ?? Date.now()}`, {
@@ -973,6 +1440,17 @@ const applyNotification = (notification: CodexRpcNotification) => {
973
1440
  if (params.item.type === 'collabAgentToolCall') {
974
1441
  applySubagentActivityItem(params.item)
975
1442
  }
1443
+ if (params.item.type === 'userMessage') {
1444
+ for (const nextMessage of itemToMessages(params.item)) {
1445
+ reconcilePendingUserMessage({
1446
+ ...nextMessage,
1447
+ pending: false
1448
+ })
1449
+ }
1450
+ status.value = 'streaming'
1451
+ return
1452
+ }
1453
+ clearThinkingPlaceholderForVisibleItem(params.item)
976
1454
  seedStreamingMessage(params.item)
977
1455
  status.value = 'streaming'
978
1456
  return
@@ -982,19 +1460,24 @@ const applyNotification = (notification: CodexRpcNotification) => {
982
1460
  if (params.item.type === 'collabAgentToolCall') {
983
1461
  applySubagentActivityItem(params.item)
984
1462
  }
985
- if (params.item.type === 'userMessage') {
986
- stripOptimisticDraftMessages()
987
- }
1463
+ clearThinkingPlaceholderForVisibleItem(params.item)
988
1464
  for (const nextMessage of itemToMessages(params.item)) {
989
- messages.value = upsertStreamingMessage(messages.value, {
1465
+ const confirmedMessage = {
990
1466
  ...nextMessage,
991
1467
  pending: false
992
- })
1468
+ }
1469
+ if (params.item.type === 'userMessage') {
1470
+ reconcilePendingUserMessage(confirmedMessage)
1471
+ continue
1472
+ }
1473
+
1474
+ messages.value = replaceStreamingMessage(messages.value, confirmedMessage)
993
1475
  }
994
1476
  return
995
1477
  }
996
1478
  case 'item/agentMessage/delta': {
997
1479
  const params = notification.params as { itemId: string, delta: string }
1480
+ clearThinkingPlaceholder()
998
1481
  appendTextPartDelta(params.itemId, params.delta, {
999
1482
  id: params.itemId,
1000
1483
  role: 'assistant',
@@ -1010,6 +1493,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
1010
1493
  }
1011
1494
  case 'item/plan/delta': {
1012
1495
  const params = notification.params as { itemId: string, delta: string }
1496
+ clearThinkingPlaceholder()
1013
1497
  appendTextPartDelta(params.itemId, params.delta, {
1014
1498
  id: params.itemId,
1015
1499
  role: 'assistant',
@@ -1026,6 +1510,7 @@ const applyNotification = (notification: CodexRpcNotification) => {
1026
1510
  case 'item/reasoning/textDelta':
1027
1511
  case 'item/reasoning/summaryTextDelta': {
1028
1512
  const params = notification.params as { itemId: string, delta: string }
1513
+ clearThinkingPlaceholder()
1029
1514
  updateMessage(params.itemId, {
1030
1515
  id: params.itemId,
1031
1516
  role: 'assistant',
@@ -1120,11 +1605,34 @@ const applyNotification = (notification: CodexRpcNotification) => {
1120
1605
  status.value = 'streaming'
1121
1606
  return
1122
1607
  }
1608
+ case 'thread/tokenUsage/updated': {
1609
+ const nextUsage = normalizeThreadTokenUsage(notification.params)
1610
+ if (nextUsage) {
1611
+ tokenUsage.value = nextUsage
1612
+ if (nextUsage.modelContextWindow != null) {
1613
+ modelContextWindow.value = nextUsage.modelContextWindow
1614
+ }
1615
+ }
1616
+ return
1617
+ }
1618
+ case 'error': {
1619
+ const params = notification.params as { error?: { message?: string } }
1620
+ const messageText = params.error?.message ?? 'The stream failed.'
1621
+ clearThinkingPlaceholder()
1622
+ pushEventMessage('stream.error', messageText)
1623
+ clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
1624
+ clearLiveStream(new Error(messageText))
1625
+ error.value = messageText
1626
+ status.value = 'error'
1627
+ return
1628
+ }
1123
1629
  case 'turn/failed': {
1124
1630
  const params = notification.params as { error?: { message?: string } }
1125
1631
  const messageText = params.error?.message ?? 'The turn failed.'
1632
+ clearThinkingPlaceholder()
1126
1633
  pushEventMessage('turn.failed', messageText)
1127
- clearLiveStream()
1634
+ clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
1635
+ clearLiveStream(new Error(messageText))
1128
1636
  error.value = messageText
1129
1637
  status.value = 'error'
1130
1638
  return
@@ -1132,13 +1640,18 @@ const applyNotification = (notification: CodexRpcNotification) => {
1132
1640
  case 'stream/error': {
1133
1641
  const params = notification.params as { message?: string }
1134
1642
  const messageText = params.message ?? 'The stream failed.'
1643
+ clearThinkingPlaceholder()
1135
1644
  pushEventMessage('stream.error', messageText)
1136
- clearLiveStream()
1645
+ clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
1646
+ clearLiveStream(new Error(messageText))
1137
1647
  error.value = messageText
1138
1648
  status.value = 'error'
1139
1649
  return
1140
1650
  }
1141
1651
  case 'turn/completed': {
1652
+ clearThinkingPlaceholder()
1653
+ clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
1654
+ error.value = null
1142
1655
  status.value = 'ready'
1143
1656
  clearLiveStream()
1144
1657
  return
@@ -1150,165 +1663,128 @@ const applyNotification = (notification: CodexRpcNotification) => {
1150
1663
 
1151
1664
  const sendMessage = async () => {
1152
1665
  const text = input.value.trim()
1153
- if (!text) {
1666
+ const submittedAttachments = attachments.value.slice()
1667
+
1668
+ if (!text && submittedAttachments.length === 0) {
1669
+ return
1670
+ }
1671
+
1672
+ try {
1673
+ await loadPromptControls()
1674
+ } catch (caughtError) {
1675
+ error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
1676
+ status.value = 'error'
1154
1677
  return
1155
1678
  }
1156
1679
 
1157
1680
  pinnedToBottom.value = true
1158
1681
  error.value = null
1159
- status.value = 'submitted'
1160
- input.value = ''
1161
- const shouldRenderOptimisticDraft = !routeThreadId.value
1162
-
1163
- if (shouldRenderOptimisticDraft) {
1164
- const optimisticMessage: ChatMessage = {
1165
- id: `local-user-${Date.now()}`,
1166
- role: 'user',
1167
- parts: [{
1168
- type: 'text',
1169
- text,
1170
- state: 'done'
1171
- }]
1172
- }
1173
-
1174
- messages.value = [...messages.value, optimisticMessage]
1682
+ attachmentError.value = null
1683
+ const submissionMethod = resolveTurnSubmissionMethod(hasActiveTurnEngagement())
1684
+ if (submissionMethod === 'turn/start') {
1685
+ status.value = 'submitted'
1175
1686
  }
1176
- let unsubscribe: (() => void) | null = null
1687
+ input.value = ''
1688
+ clearAttachments({ revoke: false })
1689
+ const optimisticMessage = buildOptimisticMessage(text, submittedAttachments)
1690
+ const optimisticMessageId = optimisticMessage.id
1691
+ rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
1692
+ messages.value = [...messages.value, optimisticMessage]
1693
+ ensureThinkingPlaceholder()
1694
+ let startedLiveStream: LiveStream | null = null
1177
1695
 
1178
1696
  try {
1179
- await ensureProjectRuntime()
1180
- const { threadId, created } = await ensureThread()
1181
1697
  const client = getClient(props.projectId)
1182
- const buffered: CodexRpcNotification[] = []
1183
- let currentTurnId: string | null = null
1184
- let completionResolved = false
1185
- let resolveCompletion: (() => void) | null = null
1186
- let rejectCompletion: ((error: Error) => void) | null = null
1187
-
1188
- const settleNotification = (notification: CodexRpcNotification) => {
1189
- if (notificationThreadId(notification) !== threadId) {
1190
- return false
1191
- }
1192
-
1193
- const turnId = notificationTurnId(notification)
1194
- if (currentTurnId && turnId && turnId !== currentTurnId) {
1195
- return false
1196
- }
1197
-
1198
- if (notification.method === 'error') {
1199
- completionResolved = true
1200
- unsubscribe?.()
1201
- if (session.liveStream?.unsubscribe === unsubscribe) {
1202
- session.liveStream = null
1203
- }
1204
- const params = notification.params as { error?: { message?: string } }
1205
- const messageText = params.error?.message ?? 'Turn failed.'
1206
- pushEventMessage('stream.error', messageText)
1207
- rejectCompletion?.(new Error(messageText))
1208
- return true
1209
- }
1210
-
1211
- applyNotification(notification)
1212
-
1213
- if (notification.method === 'turn/completed') {
1214
- completionResolved = true
1215
- unsubscribe?.()
1216
- if (session.liveStream?.unsubscribe === unsubscribe) {
1217
- session.liveStream = null
1218
- }
1219
- resolveCompletion?.()
1220
- return true
1221
- }
1222
1698
 
1223
- if (notification.method === 'turn/failed' || notification.method === 'stream/error') {
1224
- completionResolved = true
1225
- unsubscribe?.()
1226
- if (session.liveStream?.unsubscribe === unsubscribe) {
1227
- session.liveStream = null
1228
- }
1229
- rejectCompletion?.(new Error(error.value ?? 'Turn failed.'))
1230
- return true
1231
- }
1232
-
1233
- return false
1699
+ if (submissionMethod === 'turn/steer') {
1700
+ const liveStream = await ensurePendingLiveStream()
1701
+ const uploadedAttachments = await uploadAttachments(liveStream.threadId, submittedAttachments)
1702
+ liveStream.pendingUserMessageIds.push(optimisticMessageId)
1703
+ const turnId = await waitForLiveStreamTurnId(liveStream)
1704
+
1705
+ await client.request<TurnStartResponse>('turn/steer', {
1706
+ threadId: liveStream.threadId,
1707
+ expectedTurnId: turnId,
1708
+ input: buildTurnStartInput(text, uploadedAttachments),
1709
+ ...buildTurnOverrides(selectedModel.value, selectedEffort.value)
1710
+ })
1711
+ return
1234
1712
  }
1235
1713
 
1236
- clearLiveStream()
1237
-
1238
- const completion = new Promise<void>((resolve, reject) => {
1239
- resolveCompletion = resolve
1240
- rejectCompletion = (caughtError: Error) => reject(caughtError)
1241
- })
1714
+ const liveStream = await ensurePendingLiveStream()
1715
+ startedLiveStream = liveStream
1716
+ const threadId = liveStream.threadId
1717
+ const uploadedAttachments = await uploadAttachments(threadId, submittedAttachments)
1718
+ liveStream.pendingUserMessageIds.push(optimisticMessageId)
1242
1719
 
1243
- const liveStream = {
1720
+ const turnStart = await client.request<TurnStartResponse>('turn/start', {
1244
1721
  threadId,
1245
- turnId: null as string | null,
1246
- bufferedNotifications: buffered,
1247
- observedSubagentThreadIds: new Set<string>(),
1248
- unsubscribe: null as (() => void) | null
1249
- }
1722
+ input: buildTurnStartInput(text, uploadedAttachments),
1723
+ cwd: selectedProject.value?.projectPath ?? null,
1724
+ approvalPolicy: 'never',
1725
+ ...buildTurnOverrides(selectedModel.value, selectedEffort.value)
1726
+ })
1250
1727
 
1251
- unsubscribe = client.subscribe((notification) => {
1252
- const targetThreadId = notificationThreadId(notification)
1253
- if (!targetThreadId) {
1254
- return
1255
- }
1728
+ setLiveStreamTurnId(liveStream, turnStart.turn.id)
1256
1729
 
1257
- if (targetThreadId !== threadId) {
1258
- if (liveStream.observedSubagentThreadIds.has(targetThreadId)) {
1259
- applySubagentNotification(targetThreadId, notification)
1260
- }
1261
- return
1262
- }
1263
-
1264
- if (!currentTurnId) {
1265
- buffered.push(notification)
1266
- return
1730
+ for (const notification of liveStream.bufferedNotifications.splice(0, liveStream.bufferedNotifications.length)) {
1731
+ const turnId = notificationTurnId(notification)
1732
+ if (turnId && turnId !== liveStream.turnId) {
1733
+ continue
1267
1734
  }
1268
1735
 
1269
- settleNotification(notification)
1270
- })
1271
- liveStream.unsubscribe = unsubscribe
1272
- session.liveStream = liveStream
1273
-
1274
- if (created && !routeThreadId.value) {
1275
- pendingThreadId.value = threadId
1736
+ applyNotification(notification)
1276
1737
  }
1738
+ } catch (caughtError) {
1739
+ const messageText = caughtError instanceof Error ? caughtError.message : String(caughtError)
1277
1740
 
1278
- const turnStart = await client.request<TurnStartResponse>('turn/start', {
1279
- threadId,
1280
- input: [{
1281
- type: 'text',
1282
- text,
1283
- text_elements: []
1284
- }],
1285
- cwd: selectedProject.value?.projectPath ?? null,
1286
- approvalPolicy: 'never'
1287
- })
1288
-
1289
- currentTurnId = turnStart.turn.id
1290
- liveStream.turnId = turnStart.turn.id
1741
+ clearThinkingPlaceholder()
1742
+ untrackPendingUserMessage(optimisticMessageId)
1743
+ removeOptimisticMessage(optimisticMessageId)
1291
1744
 
1292
- for (const notification of buffered.splice(0, buffered.length)) {
1293
- if (settleNotification(notification) && completionResolved) {
1294
- break
1745
+ if (submissionMethod === 'turn/start') {
1746
+ if (startedLiveStream && session.liveStream === startedLiveStream) {
1747
+ clearPendingOptimisticMessages(clearLiveStream(new Error(messageText)))
1295
1748
  }
1749
+ session.pendingLiveStream = null
1750
+ restoreDraftIfPristine(text, submittedAttachments)
1751
+ error.value = messageText
1752
+ status.value = 'error'
1753
+ return
1296
1754
  }
1297
1755
 
1298
- await completion
1299
- status.value = 'ready'
1756
+ restoreDraftIfPristine(text, submittedAttachments)
1757
+ error.value = messageText
1758
+ }
1759
+ }
1760
+
1761
+ const stopActiveTurn = async () => {
1762
+ if (!hasActiveTurnEngagement()) {
1763
+ return
1764
+ }
1300
1765
 
1301
- if (routeThreadId.value) {
1302
- await hydrateThread(threadId)
1766
+ let liveStream: LiveStream | null = null
1767
+
1768
+ try {
1769
+ liveStream = await ensurePendingLiveStream()
1770
+ if (liveStream.interruptRequested) {
1303
1771
  return
1304
1772
  }
1773
+
1774
+ setLiveStreamInterruptRequested(liveStream, true)
1775
+ error.value = null
1776
+ const client = getClient(props.projectId)
1777
+ const turnId = await waitForLiveStreamTurnId(liveStream)
1778
+ await client.request('turn/interrupt', {
1779
+ threadId: liveStream.threadId,
1780
+ turnId
1781
+ })
1782
+ liveStream.interruptAcknowledged = true
1305
1783
  } catch (caughtError) {
1306
- unsubscribe?.()
1307
- if (session.liveStream?.unsubscribe === unsubscribe) {
1308
- session.liveStream = null
1784
+ if (liveStream) {
1785
+ setLiveStreamInterruptRequested(liveStream, false)
1309
1786
  }
1310
1787
  error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
1311
- status.value = 'error'
1312
1788
  }
1313
1789
  }
1314
1790
 
@@ -1335,6 +1811,7 @@ onMounted(() => {
1335
1811
  void refreshProjects()
1336
1812
  }
1337
1813
 
1814
+ void loadPromptControls()
1338
1815
  void scheduleScrollToBottom('auto')
1339
1816
  })
1340
1817
 
@@ -1388,6 +1865,18 @@ watch(status, (nextStatus, previousStatus) => {
1388
1865
 
1389
1866
  void scheduleScrollToBottom(nextStatus === 'streaming' ? 'auto' : 'smooth')
1390
1867
  }, { flush: 'post' })
1868
+
1869
+ watch([selectedModel, availableModels], () => {
1870
+ const nextSelection = coercePromptSelection(effectiveModelList.value, selectedModel.value, selectedEffort.value)
1871
+ if (selectedModel.value !== nextSelection.model) {
1872
+ selectedModel.value = nextSelection.model
1873
+ return
1874
+ }
1875
+
1876
+ if (selectedEffort.value !== nextSelection.effort) {
1877
+ selectedEffort.value = nextSelection.effort
1878
+ }
1879
+ }, { flush: 'sync' })
1391
1880
  </script>
1392
1881
 
1393
1882
  <template>
@@ -1461,38 +1950,271 @@ watch(status, (nextStatus, previousStatus) => {
1461
1950
  compact
1462
1951
  >
1463
1952
  <template #content="{ message }">
1464
- <MessageContent :message="message as ChatMessage" />
1953
+ <MessageContent
1954
+ :message="message as ChatMessage"
1955
+ :project-id="projectId"
1956
+ />
1465
1957
  </template>
1466
1958
  </UChatMessages>
1467
1959
  </div>
1468
1960
 
1469
1961
  <div class="sticky bottom-0 shrink-0 border-t border-default bg-default/95 px-4 py-3 backdrop-blur md:px-6">
1470
1962
  <div class="mx-auto w-full max-w-5xl">
1471
- <TunnelNotice class="mb-3" />
1472
- <UAlert
1473
- v-if="error"
1474
- color="error"
1475
- variant="soft"
1476
- icon="i-lucide-circle-alert"
1477
- :title="error"
1478
- class="mb-3"
1479
- />
1480
-
1481
- <UChatPrompt
1482
- v-model="input"
1483
- placeholder="Describe the change you want Codex to make"
1484
- :error="submitError"
1485
- :disabled="isBusy"
1486
- autoresize
1487
- @submit.prevent="sendMessage"
1488
- @keydown.enter="onPromptEnter"
1489
- @compositionstart="onCompositionStart"
1490
- @compositionend="onCompositionEnd"
1491
- >
1492
- <UChatPromptSubmit
1493
- :status="status"
1963
+ <UAlert
1964
+ v-if="composerError"
1965
+ color="error"
1966
+ variant="soft"
1967
+ icon="i-lucide-circle-alert"
1968
+ :title="composerError"
1969
+ class="mb-3"
1494
1970
  />
1495
- </UChatPrompt>
1971
+
1972
+ <div
1973
+ class="relative"
1974
+ @dragenter="onDragEnter"
1975
+ @dragleave="onDragLeave"
1976
+ @dragover="onDragOver"
1977
+ @drop="onDrop"
1978
+ >
1979
+ <div
1980
+ v-if="isDragging"
1981
+ class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-3xl border border-dashed border-primary/50 bg-primary/10 text-sm font-medium text-primary backdrop-blur-sm"
1982
+ >
1983
+ Drop images to attach them
1984
+ </div>
1985
+
1986
+ <UChatPrompt
1987
+ v-model="input"
1988
+ placeholder="Describe the change you want Codex to make"
1989
+ :error="submitError"
1990
+ :disabled="isComposerDisabled"
1991
+ autoresize
1992
+ @submit.prevent="sendMessage"
1993
+ @keydown.enter="onPromptEnter"
1994
+ @compositionstart="onCompositionStart"
1995
+ @compositionend="onCompositionEnd"
1996
+ @paste="onPaste"
1997
+ >
1998
+ <template #header>
1999
+ <div
2000
+ v-if="attachments.length"
2001
+ class="flex flex-wrap gap-2 pb-2"
2002
+ >
2003
+ <div
2004
+ v-for="attachment in attachments"
2005
+ :key="attachment.id"
2006
+ class="flex max-w-full items-center gap-2 rounded-2xl border border-default bg-elevated/35 px-2 py-1.5"
2007
+ >
2008
+ <img
2009
+ :src="attachment.previewUrl"
2010
+ :alt="attachment.name"
2011
+ class="size-10 rounded-xl object-cover"
2012
+ >
2013
+ <div class="min-w-0">
2014
+ <div class="max-w-40 truncate text-xs font-medium text-highlighted">
2015
+ {{ attachment.name }}
2016
+ </div>
2017
+ <div class="text-[11px] text-muted">
2018
+ {{ formatAttachmentSize(attachment.size) }}
2019
+ </div>
2020
+ </div>
2021
+ <UButton
2022
+ type="button"
2023
+ color="neutral"
2024
+ variant="ghost"
2025
+ size="xs"
2026
+ icon="i-lucide-x"
2027
+ :disabled="isComposerDisabled"
2028
+ class="rounded-full"
2029
+ :aria-label="`Remove ${attachment.name}`"
2030
+ @click="removeAttachment(attachment.id)"
2031
+ />
2032
+ </div>
2033
+ </div>
2034
+ </template>
2035
+
2036
+ <UChatPromptSubmit
2037
+ :status="promptSubmitStatus"
2038
+ @stop="stopActiveTurn"
2039
+ />
2040
+
2041
+ <template #footer>
2042
+ <input
2043
+ ref="fileInput"
2044
+ type="file"
2045
+ accept="image/*"
2046
+ class="hidden"
2047
+ :disabled="isComposerDisabled"
2048
+ @change="onFileInputChange"
2049
+ >
2050
+
2051
+ <div class="flex w-full flex-wrap items-center gap-2 pt-1">
2052
+ <div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
2053
+ <UButton
2054
+ type="button"
2055
+ color="neutral"
2056
+ variant="ghost"
2057
+ size="sm"
2058
+ icon="i-lucide-plus"
2059
+ :disabled="isComposerDisabled"
2060
+ class="size-8 shrink-0 justify-center rounded-full border border-default/70"
2061
+ :ui="{ leadingIcon: 'size-4', base: 'px-0' }"
2062
+ aria-label="Attach image"
2063
+ @click="openFilePicker"
2064
+ />
2065
+
2066
+ <USelect
2067
+ v-model="selectedModel"
2068
+ :items="modelSelectItems"
2069
+ color="neutral"
2070
+ variant="ghost"
2071
+ size="sm"
2072
+ :loading="promptControlsLoading"
2073
+ :disabled="isComposerDisabled"
2074
+ class="min-w-0 flex-1 sm:max-w-52 sm:flex-none"
2075
+ :ui="{ base: 'rounded-full border border-default/70 bg-default/70', value: 'truncate', content: 'min-w-56' }"
2076
+ />
2077
+
2078
+ <USelect
2079
+ v-model="selectedEffort"
2080
+ :items="effortSelectItems"
2081
+ color="neutral"
2082
+ variant="ghost"
2083
+ size="sm"
2084
+ :disabled="isComposerDisabled"
2085
+ class="min-w-0 flex-1 sm:max-w-36 sm:flex-none"
2086
+ :ui="{ base: 'rounded-full border border-default/70 bg-default/70', value: 'truncate' }"
2087
+ />
2088
+ </div>
2089
+
2090
+ <div class="ml-auto flex shrink-0 items-center">
2091
+ <UPopover
2092
+ :content="{ side: 'top', align: 'end' }"
2093
+ arrow
2094
+ >
2095
+ <button
2096
+ type="button"
2097
+ class="flex h-8 shrink-0 items-center gap-2 px-0 text-left text-muted"
2098
+ >
2099
+ <span class="relative flex size-7 items-center justify-center">
2100
+ <svg
2101
+ viewBox="0 0 36 36"
2102
+ class="size-7 -rotate-90"
2103
+ aria-hidden="true"
2104
+ >
2105
+ <circle
2106
+ cx="18"
2107
+ cy="18"
2108
+ r="15.5"
2109
+ fill="none"
2110
+ stroke-width="3"
2111
+ pathLength="100"
2112
+ class="stroke-current text-muted/20"
2113
+ stroke-dasharray="100 100"
2114
+ />
2115
+ <circle
2116
+ cx="18"
2117
+ cy="18"
2118
+ r="15.5"
2119
+ fill="none"
2120
+ stroke-width="3"
2121
+ pathLength="100"
2122
+ class="stroke-current text-primary"
2123
+ stroke-linecap="round"
2124
+ :stroke-dasharray="`${contextUsedPercent} 100`"
2125
+ />
2126
+ </svg>
2127
+ <span class="absolute text-[9px] font-semibold text-highlighted">
2128
+ {{ contextIndicatorLabel }}
2129
+ </span>
2130
+ </span>
2131
+
2132
+ <span class="text-[11px] leading-none text-muted">context window</span>
2133
+ </button>
2134
+
2135
+ <template #content>
2136
+ <div class="w-72 space-y-3 p-4">
2137
+ <div class="space-y-1">
2138
+ <div class="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
2139
+ Context Window
2140
+ </div>
2141
+ <div class="text-sm font-medium text-highlighted">
2142
+ {{ selectedModelOption?.displayName ?? 'Selected model' }}
2143
+ </div>
2144
+ </div>
2145
+
2146
+ <div
2147
+ v-if="contextWindowState.contextWindow && contextWindowState.usedTokens !== null"
2148
+ class="grid grid-cols-2 gap-3 text-sm"
2149
+ >
2150
+ <div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
2151
+ <div class="text-[11px] uppercase tracking-[0.18em] text-muted">
2152
+ Remaining
2153
+ </div>
2154
+ <div class="mt-1 font-semibold text-highlighted">
2155
+ {{ Math.round(contextWindowState.remainingPercent ?? 0) }}%
2156
+ </div>
2157
+ <div class="text-xs text-muted">
2158
+ {{ formatCompactTokenCount(contextWindowState.remainingTokens ?? 0) }} tokens
2159
+ </div>
2160
+ </div>
2161
+ <div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
2162
+ <div class="text-[11px] uppercase tracking-[0.18em] text-muted">
2163
+ Used
2164
+ </div>
2165
+ <div class="mt-1 font-semibold text-highlighted">
2166
+ {{ formatCompactTokenCount(contextWindowState.usedTokens ?? 0) }}
2167
+ </div>
2168
+ <div class="text-xs text-muted">
2169
+ of {{ formatCompactTokenCount(contextWindowState.contextWindow) }}
2170
+ </div>
2171
+ </div>
2172
+ </div>
2173
+
2174
+ <div
2175
+ v-else
2176
+ class="rounded-2xl border border-default bg-elevated/35 px-3 py-2 text-sm text-muted"
2177
+ >
2178
+ <div v-if="contextWindowState.contextWindow">
2179
+ Live token usage will appear after the next turn completes.
2180
+ </div>
2181
+ <div v-else>
2182
+ Context window details are not available from the runtime yet.
2183
+ </div>
2184
+ </div>
2185
+
2186
+ <div class="grid grid-cols-2 gap-3 text-sm">
2187
+ <div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
2188
+ <div class="text-[11px] uppercase tracking-[0.18em] text-muted">
2189
+ Input
2190
+ </div>
2191
+ <div class="mt-1 font-semibold text-highlighted">
2192
+ {{ formatCompactTokenCount(tokenUsage?.totalInputTokens ?? 0) }}
2193
+ </div>
2194
+ <div class="text-xs text-muted">
2195
+ cached {{ formatCompactTokenCount(tokenUsage?.totalCachedInputTokens ?? 0) }}
2196
+ </div>
2197
+ </div>
2198
+ <div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
2199
+ <div class="text-[11px] uppercase tracking-[0.18em] text-muted">
2200
+ Output
2201
+ </div>
2202
+ <div class="mt-1 font-semibold text-highlighted">
2203
+ {{ formatCompactTokenCount(tokenUsage?.totalOutputTokens ?? 0) }}
2204
+ </div>
2205
+ <div class="text-xs text-muted">
2206
+ effort {{ formatReasoningEffortLabel(selectedEffort) }}
2207
+ </div>
2208
+ </div>
2209
+ </div>
2210
+ </div>
2211
+ </template>
2212
+ </UPopover>
2213
+ </div>
2214
+ </div>
2215
+ </template>
2216
+ </UChatPrompt>
2217
+ </div>
1496
2218
  </div>
1497
2219
  </div>
1498
2220
  </section>