@codori/client 0.0.2 → 0.0.3

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,7 +2,16 @@
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'
@@ -19,7 +28,10 @@ import {
19
28
  type ItemData,
20
29
  type McpToolCallItem
21
30
  } from '~~/shared/codex-chat'
31
+ import { buildTurnStartInput } from '~~/shared/chat-attachments'
22
32
  import {
33
+ type ConfigReadResponse,
34
+ type ModelListResponse,
23
35
  notificationThreadId,
24
36
  notificationTurnId,
25
37
  type CodexRpcNotification,
@@ -30,6 +42,21 @@ import {
30
42
  type ThreadStartResponse,
31
43
  type TurnStartResponse
32
44
  } from '~~/shared/codex-rpc'
45
+ import {
46
+ buildTurnOverrides,
47
+ coercePromptSelection,
48
+ ensureModelOption,
49
+ FALLBACK_MODELS,
50
+ formatCompactTokenCount,
51
+ formatReasoningEffortLabel,
52
+ normalizeConfigDefaults,
53
+ normalizeModelList,
54
+ normalizeThreadTokenUsage,
55
+ resolveContextWindowState,
56
+ resolveEffortOptions,
57
+ visibleModelOptions,
58
+ type ReasoningEffort
59
+ } from '~~/shared/chat-prompt-controls'
33
60
  import { toProjectThreadRoute } from '~~/shared/codori'
34
61
 
35
62
  const props = defineProps<{
@@ -50,6 +77,25 @@ const {
50
77
  onCompositionEnd,
51
78
  shouldSubmit
52
79
  } = useChatSubmitGuard()
80
+ const {
81
+ attachments,
82
+ isDragging,
83
+ isUploading,
84
+ error: attachmentError,
85
+ fileInput,
86
+ removeAttachment,
87
+ replaceAttachments,
88
+ clearAttachments,
89
+ discardSnapshot,
90
+ openFilePicker,
91
+ onFileInputChange,
92
+ onPaste,
93
+ onDragEnter,
94
+ onDragLeave,
95
+ onDragOver,
96
+ onDrop,
97
+ uploadAttachments
98
+ } = useChatAttachments(props.projectId)
53
99
 
54
100
  const input = ref('')
55
101
  const scrollViewport = ref<HTMLElement | null>(null)
@@ -64,12 +110,39 @@ const {
64
110
  threadTitle,
65
111
  pendingThreadId,
66
112
  autoRedirectThreadId,
67
- loadVersion
113
+ loadVersion,
114
+ promptControlsLoaded,
115
+ promptControlsLoading,
116
+ availableModels,
117
+ selectedModel,
118
+ selectedEffort,
119
+ modelContextWindow,
120
+ tokenUsage
68
121
  } = session
69
122
 
70
123
  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')
124
+ const composerError = computed(() => attachmentError.value ?? error.value)
125
+ const submitError = computed(() => composerError.value ? new Error(composerError.value) : undefined)
126
+ const interruptRequested = ref(false)
127
+ const isBusy = computed(() =>
128
+ status.value === 'submitted'
129
+ || status.value === 'streaming'
130
+ || isUploading.value
131
+ )
132
+ const hasDraftContent = computed(() =>
133
+ input.value.trim().length > 0
134
+ || attachments.value.length > 0
135
+ )
136
+ const isComposerDisabled = computed(() =>
137
+ isUploading.value
138
+ || interruptRequested.value
139
+ )
140
+ const promptSubmitStatus = computed(() =>
141
+ resolvePromptSubmitStatus({
142
+ status: status.value,
143
+ hasDraftContent: hasDraftContent.value
144
+ })
145
+ )
73
146
  const routeThreadId = computed(() => props.threadId ?? null)
74
147
  const projectTitle = computed(() => selectedProject.value?.projectId ?? props.projectId)
75
148
  const showWelcomeState = computed(() =>
@@ -98,7 +171,134 @@ const starterPrompts = computed(() => {
98
171
  ]
99
172
  })
100
173
 
174
+ const hasKnownThreadUsage = computed(() => !activeThreadId.value || tokenUsage.value !== null)
175
+ const effectiveModelList = computed(() => {
176
+ const withSelected = ensureModelOption(
177
+ availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS,
178
+ selectedModel.value,
179
+ selectedEffort.value
180
+ )
181
+ return visibleModelOptions(withSelected)
182
+ })
183
+ const selectedModelOption = computed(() =>
184
+ effectiveModelList.value.find(model => model.model === selectedModel.value)
185
+ ?? effectiveModelList.value[0]
186
+ ?? FALLBACK_MODELS[0]
187
+ )
188
+ const modelSelectItems = computed(() =>
189
+ effectiveModelList.value.map(model => ({
190
+ label: model.displayName,
191
+ value: model.model
192
+ }))
193
+ )
194
+ const effortOptions = computed(() => resolveEffortOptions(effectiveModelList.value, selectedModel.value))
195
+ const effortSelectItems = computed(() =>
196
+ effortOptions.value.map(effort => ({
197
+ label: formatReasoningEffortLabel(effort),
198
+ value: effort
199
+ }))
200
+ )
201
+ const contextWindowState = computed(() =>
202
+ resolveContextWindowState(tokenUsage.value, modelContextWindow.value, hasKnownThreadUsage.value)
203
+ )
204
+ const contextUsedPercent = computed(() => contextWindowState.value.usedPercent ?? 0)
205
+ const contextIndicatorLabel = computed(() => {
206
+ const remainingPercent = contextWindowState.value.remainingPercent
207
+ return remainingPercent == null ? 'ctx' : `${Math.round(remainingPercent)}%`
208
+ })
209
+
210
+ const normalizePromptSelection = (
211
+ preferredModel?: string | null,
212
+ preferredEffort?: ReasoningEffort | null
213
+ ) => {
214
+ const withSelected = ensureModelOption(
215
+ availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS,
216
+ preferredModel ?? selectedModel.value,
217
+ preferredEffort ?? selectedEffort.value
218
+ )
219
+ const visibleModels = visibleModelOptions(withSelected)
220
+ const nextSelection = coercePromptSelection(visibleModels, preferredModel ?? selectedModel.value, preferredEffort ?? selectedEffort.value)
221
+
222
+ availableModels.value = visibleModels
223
+ if (selectedModel.value !== nextSelection.model) {
224
+ selectedModel.value = nextSelection.model
225
+ }
226
+ if (selectedEffort.value !== nextSelection.effort) {
227
+ selectedEffort.value = nextSelection.effort
228
+ }
229
+ }
230
+
231
+ const syncPromptSelectionFromThread = (
232
+ model: string | null | undefined,
233
+ effort: ReasoningEffort | null | undefined
234
+ ) => {
235
+ normalizePromptSelection(model ?? null, effort ?? null)
236
+ }
237
+
238
+ const loadPromptControls = async () => {
239
+ if (promptControlsLoaded.value) {
240
+ return
241
+ }
242
+
243
+ if (promptControlsPromise) {
244
+ return await promptControlsPromise
245
+ }
246
+
247
+ promptControlsPromise = (async () => {
248
+ promptControlsLoading.value = true
249
+
250
+ try {
251
+ await ensureProjectRuntime()
252
+ const client = getClient(props.projectId)
253
+ const initialModel = selectedModel.value
254
+ const initialEffort = selectedEffort.value
255
+ let nextModels = availableModels.value.length > 0 ? availableModels.value : FALLBACK_MODELS
256
+ let defaultModel: string | null = null
257
+ let defaultEffort: ReasoningEffort | null = null
258
+
259
+ const [modelsResponse, configResponse] = await Promise.allSettled([
260
+ client.request<ModelListResponse>('model/list'),
261
+ client.request<ConfigReadResponse>('config/read')
262
+ ])
263
+
264
+ if (modelsResponse.status === 'fulfilled') {
265
+ nextModels = visibleModelOptions(normalizeModelList(modelsResponse.value))
266
+ } else {
267
+ nextModels = visibleModelOptions(nextModels)
268
+ }
269
+
270
+ if (configResponse.status === 'fulfilled') {
271
+ const defaults = normalizeConfigDefaults(configResponse.value)
272
+ if (defaults.contextWindow != null) {
273
+ modelContextWindow.value = defaults.contextWindow
274
+ }
275
+ defaultModel = defaults.model
276
+ defaultEffort = defaults.effort
277
+ }
278
+
279
+ availableModels.value = nextModels
280
+
281
+ const preferredModel = selectedModel.value !== initialModel
282
+ ? selectedModel.value
283
+ : defaultModel ?? initialModel
284
+ const preferredEffort = selectedEffort.value !== initialEffort
285
+ ? selectedEffort.value
286
+ : defaultEffort ?? initialEffort
287
+
288
+ normalizePromptSelection(preferredModel, preferredEffort)
289
+ promptControlsLoaded.value = true
290
+ } finally {
291
+ promptControlsLoading.value = false
292
+ promptControlsPromise = null
293
+ }
294
+ })()
295
+
296
+ return await promptControlsPromise
297
+ }
298
+
101
299
  const subagentBootstrapPromises = new Map<string, Promise<void>>()
300
+ const optimisticAttachmentSnapshots = new Map<string, DraftAttachment[]>()
301
+ let promptControlsPromise: Promise<void> | null = null
102
302
 
103
303
  const isActiveTurnStatus = (value: string | null | undefined) => {
104
304
  if (!value) {
@@ -113,13 +313,249 @@ const currentLiveStream = () =>
113
313
  ? session.liveStream
114
314
  : null
115
315
 
116
- const clearLiveStream = () => {
117
- session.liveStream?.unsubscribe?.()
118
- session.liveStream = null
316
+ const hasActiveTurnEngagement = () =>
317
+ Boolean(currentLiveStream() || session.pendingLiveStream)
318
+
319
+ const rejectLiveStreamTurnWaiters = (liveStream: LiveStream, error: Error) => {
320
+ const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
321
+ for (const waiter of waiters) {
322
+ waiter.reject(error)
323
+ }
324
+ }
325
+
326
+ const createLiveStreamState = (
327
+ threadId: string,
328
+ bufferedNotifications: CodexRpcNotification[] = []
329
+ ): LiveStream => ({
330
+ threadId,
331
+ turnId: null,
332
+ bufferedNotifications,
333
+ observedSubagentThreadIds: new Set<string>(),
334
+ pendingUserMessageIds: [],
335
+ turnIdWaiters: [],
336
+ interruptRequested: false,
337
+ interruptAcknowledged: false,
338
+ unsubscribe: null
339
+ })
340
+
341
+ const setSessionLiveStream = (liveStream: LiveStream | null) => {
342
+ session.liveStream = liveStream
343
+ interruptRequested.value = liveStream?.interruptRequested === true
344
+ }
345
+
346
+ const setLiveStreamInterruptRequested = (liveStream: LiveStream, nextValue: boolean) => {
347
+ liveStream.interruptRequested = nextValue
348
+
349
+ if (session.liveStream === liveStream) {
350
+ interruptRequested.value = nextValue
351
+ }
352
+ }
353
+
354
+ const setLiveStreamTurnId = (liveStream: LiveStream, turnId: string | null) => {
355
+ liveStream.turnId = turnId
356
+
357
+ if (!turnId) {
358
+ return
359
+ }
360
+
361
+ liveStream.interruptAcknowledged = false
362
+ const waiters = liveStream.turnIdWaiters.splice(0, liveStream.turnIdWaiters.length)
363
+ for (const waiter of waiters) {
364
+ waiter.resolve(turnId)
365
+ }
366
+ }
367
+
368
+ const waitForLiveStreamTurnId = async (liveStream: LiveStream) => {
369
+ if (liveStream.turnId) {
370
+ return liveStream.turnId
371
+ }
372
+
373
+ return await new Promise<string>((resolve, reject) => {
374
+ liveStream.turnIdWaiters.push({ resolve, reject })
375
+ })
376
+ }
377
+
378
+ const clearLiveStream = (reason?: Error) => {
379
+ const liveStream = session.liveStream
380
+ if (!liveStream) {
381
+ interruptRequested.value = false
382
+ return null
383
+ }
384
+
385
+ liveStream.unsubscribe?.()
386
+ rejectLiveStreamTurnWaiters(liveStream, reason ?? new Error('The active turn is no longer available.'))
387
+ setSessionLiveStream(null)
388
+ return liveStream
389
+ }
390
+
391
+ const ensurePendingLiveStream = async () => {
392
+ const existingLiveStream = currentLiveStream()
393
+ if (existingLiveStream) {
394
+ return existingLiveStream
395
+ }
396
+
397
+ if (session.pendingLiveStream) {
398
+ return await session.pendingLiveStream
399
+ }
400
+
401
+ const pendingLiveStream = (async () => {
402
+ await ensureProjectRuntime()
403
+ const { threadId, created } = await ensureThread()
404
+ const client = getClient(props.projectId)
405
+ const buffered: CodexRpcNotification[] = []
406
+ clearLiveStream()
407
+
408
+ const liveStream = createLiveStreamState(threadId, buffered)
409
+ liveStream.unsubscribe = client.subscribe((notification) => {
410
+ const targetThreadId = notificationThreadId(notification)
411
+ if (!targetThreadId) {
412
+ return
413
+ }
414
+
415
+ if (targetThreadId !== threadId) {
416
+ if (liveStream.observedSubagentThreadIds.has(targetThreadId)) {
417
+ applySubagentNotification(targetThreadId, notification)
418
+ }
419
+ return
420
+ }
421
+
422
+ if (!liveStream.turnId) {
423
+ buffered.push(notification)
424
+ return
425
+ }
426
+
427
+ const turnId = notificationTurnId(notification)
428
+ if (turnId && turnId !== liveStream.turnId) {
429
+ return
430
+ }
431
+
432
+ applyNotification(notification)
433
+ })
434
+
435
+ setSessionLiveStream(liveStream)
436
+
437
+ if (created && !routeThreadId.value) {
438
+ pendingThreadId.value = threadId
439
+ }
440
+
441
+ return liveStream
442
+ })()
443
+
444
+ session.pendingLiveStream = pendingLiveStream
445
+
446
+ try {
447
+ return await pendingLiveStream
448
+ } finally {
449
+ if (session.pendingLiveStream === pendingLiveStream) {
450
+ session.pendingLiveStream = null
451
+ }
452
+ }
453
+ }
454
+
455
+ const createOptimisticMessageId = () =>
456
+ typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
457
+ ? `local-user-${crypto.randomUUID()}`
458
+ : `local-user-${Date.now()}-${Math.random().toString(16).slice(2)}`
459
+
460
+ const buildOptimisticMessage = (text: string, submittedAttachments: DraftAttachment[]): ChatMessage => ({
461
+ id: createOptimisticMessageId(),
462
+ role: 'user',
463
+ parts: [
464
+ ...(text.trim()
465
+ ? [{
466
+ type: 'text' as const,
467
+ text,
468
+ state: 'done' as const
469
+ }]
470
+ : []),
471
+ ...submittedAttachments.map((attachment) => ({
472
+ type: 'attachment' as const,
473
+ attachment: {
474
+ kind: 'image' as const,
475
+ name: attachment.name,
476
+ mediaType: attachment.mediaType,
477
+ url: attachment.previewUrl
478
+ }
479
+ }))
480
+ ]
481
+ })
482
+
483
+ const rememberOptimisticAttachments = (messageId: string, submittedAttachments: DraftAttachment[]) => {
484
+ if (submittedAttachments.length === 0) {
485
+ return
486
+ }
487
+
488
+ optimisticAttachmentSnapshots.set(messageId, submittedAttachments)
489
+ }
490
+
491
+ const formatAttachmentSize = (size: number) => {
492
+ if (size >= 1024 * 1024) {
493
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`
494
+ }
495
+
496
+ if (size >= 1024) {
497
+ return `${Math.round(size / 1024)} KB`
498
+ }
499
+
500
+ return `${size} B`
501
+ }
502
+
503
+ const removeOptimisticMessage = (messageId: string) => {
504
+ messages.value = removeChatMessage(messages.value, messageId)
505
+ optimisticAttachmentSnapshots.delete(messageId)
506
+ }
507
+
508
+ const restoreDraftIfPristine = (text: string, submittedAttachments: DraftAttachment[]) => {
509
+ if (!input.value.trim()) {
510
+ input.value = text
511
+ }
512
+
513
+ if (attachments.value.length === 0 && submittedAttachments.length > 0) {
514
+ replaceAttachments(submittedAttachments)
515
+ }
516
+ }
517
+
518
+ const untrackPendingUserMessage = (messageId: string) => {
519
+ const liveStream = currentLiveStream()
520
+ if (!liveStream) {
521
+ return
522
+ }
523
+
524
+ liveStream.pendingUserMessageIds = removePendingUserMessageId(liveStream.pendingUserMessageIds, messageId)
119
525
  }
120
526
 
121
- const stripOptimisticDraftMessages = () => {
122
- messages.value = messages.value.filter(message => !message.id.startsWith('local-user-'))
527
+ const reconcilePendingUserMessage = (confirmedMessage: ChatMessage) => {
528
+ const liveStream = currentLiveStream()
529
+ const optimisticMessageId = liveStream?.pendingUserMessageIds.shift() ?? null
530
+ if (!optimisticMessageId) {
531
+ messages.value = upsertStreamingMessage(messages.value, confirmedMessage)
532
+ return
533
+ }
534
+
535
+ const pendingAttachments = optimisticAttachmentSnapshots.get(optimisticMessageId)
536
+ if (pendingAttachments) {
537
+ discardSnapshot(pendingAttachments)
538
+ optimisticAttachmentSnapshots.delete(optimisticMessageId)
539
+ }
540
+
541
+ messages.value = reconcileOptimisticUserMessage(messages.value, optimisticMessageId, confirmedMessage)
542
+ }
543
+
544
+ const clearPendingOptimisticMessages = (liveStream: LiveStream | null, options?: { discardSnapshots?: boolean }) => {
545
+ if (!liveStream) {
546
+ return
547
+ }
548
+
549
+ for (const messageId of liveStream.pendingUserMessageIds) {
550
+ messages.value = removeChatMessage(messages.value, messageId)
551
+ const pendingAttachments = optimisticAttachmentSnapshots.get(messageId)
552
+ if (options?.discardSnapshots && pendingAttachments) {
553
+ discardSnapshot(pendingAttachments)
554
+ }
555
+ optimisticAttachmentSnapshots.delete(messageId)
556
+ }
557
+
558
+ liveStream.pendingUserMessageIds = []
123
559
  }
124
560
 
125
561
  const resolveThreadTitle = (thread: { name: string | null, preview: string, id: string }) => {
@@ -249,22 +685,21 @@ const hydrateThread = async (threadId: string) => {
249
685
  const requestVersion = loadVersion.value + 1
250
686
  loadVersion.value = requestVersion
251
687
  error.value = null
688
+ tokenUsage.value = null
252
689
 
253
690
  try {
254
691
  await ensureProjectRuntime()
255
692
  const client = getClient(props.projectId)
256
693
  activeThreadId.value = threadId
257
694
 
258
- const existingLiveStream = currentLiveStream()
695
+ const existingLiveStream = session.liveStream
259
696
 
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
- }
697
+ if (existingLiveStream && existingLiveStream.threadId !== threadId) {
698
+ clearLiveStream()
699
+ }
700
+
701
+ if (!session.liveStream) {
702
+ const nextLiveStream = createLiveStreamState(threadId)
268
703
 
269
704
  nextLiveStream.unsubscribe = client.subscribe((notification) => {
270
705
  const targetThreadId = notificationThreadId(notification)
@@ -292,10 +727,10 @@ const hydrateThread = async (threadId: string) => {
292
727
  applyNotification(notification)
293
728
  })
294
729
 
295
- session.liveStream = nextLiveStream
730
+ setSessionLiveStream(nextLiveStream)
296
731
  }
297
732
 
298
- await client.request<ThreadResumeResponse>('thread/resume', {
733
+ const resumeResponse = await client.request<ThreadResumeResponse>('thread/resume', {
299
734
  threadId,
300
735
  cwd: selectedProject.value?.projectPath ?? null,
301
736
  approvalPolicy: 'never',
@@ -310,6 +745,10 @@ const hydrateThread = async (threadId: string) => {
310
745
  return
311
746
  }
312
747
 
748
+ syncPromptSelectionFromThread(
749
+ resumeResponse.model ?? null,
750
+ (resumeResponse.reasoningEffort as ReasoningEffort | null | undefined) ?? null
751
+ )
313
752
  activeThreadId.value = response.thread.id
314
753
  threadTitle.value = resolveThreadTitle(response.thread)
315
754
  messages.value = threadToMessages(response.thread)
@@ -327,7 +766,7 @@ const hydrateThread = async (threadId: string) => {
327
766
  return
328
767
  }
329
768
 
330
- session.liveStream.turnId = activeTurn.id
769
+ setLiveStreamTurnId(session.liveStream, activeTurn.id)
331
770
  status.value = 'streaming'
332
771
 
333
772
  const pendingNotifications = session.liveStream.bufferedNotifications.splice(0, session.liveStream.bufferedNotifications.length)
@@ -348,6 +787,7 @@ const hydrateThread = async (threadId: string) => {
348
787
 
349
788
  const resetDraftThread = () => {
350
789
  clearLiveStream()
790
+ session.pendingLiveStream = null
351
791
  activeThreadId.value = null
352
792
  threadTitle.value = null
353
793
  pendingThreadId.value = null
@@ -355,6 +795,7 @@ const resetDraftThread = () => {
355
795
  messages.value = []
356
796
  subagentPanels.value = []
357
797
  error.value = null
798
+ tokenUsage.value = null
358
799
  status.value = 'ready'
359
800
  }
360
801
 
@@ -947,6 +1388,11 @@ const applySubagentNotification = (threadId: string, notification: CodexRpcNotif
947
1388
  }
948
1389
 
949
1390
  const applyNotification = (notification: CodexRpcNotification) => {
1391
+ const liveStream = currentLiveStream()
1392
+ if (liveStream?.interruptAcknowledged && shouldIgnoreNotificationAfterInterrupt(notification.method)) {
1393
+ return
1394
+ }
1395
+
950
1396
  switch (notification.method) {
951
1397
  case 'thread/started': {
952
1398
  const nextThreadId = notificationThreadId(notification)
@@ -959,6 +1405,10 @@ const applyNotification = (notification: CodexRpcNotification) => {
959
1405
  return
960
1406
  }
961
1407
  case 'turn/started': {
1408
+ if (liveStream) {
1409
+ setLiveStreamTurnId(liveStream, notificationTurnId(notification))
1410
+ setLiveStreamInterruptRequested(liveStream, false)
1411
+ }
962
1412
  messages.value = upsertStreamingMessage(
963
1413
  messages.value,
964
1414
  eventToMessage(`event-turn-started-${notificationTurnId(notification) ?? Date.now()}`, {
@@ -973,6 +1423,16 @@ const applyNotification = (notification: CodexRpcNotification) => {
973
1423
  if (params.item.type === 'collabAgentToolCall') {
974
1424
  applySubagentActivityItem(params.item)
975
1425
  }
1426
+ if (params.item.type === 'userMessage') {
1427
+ for (const nextMessage of itemToMessages(params.item)) {
1428
+ reconcilePendingUserMessage({
1429
+ ...nextMessage,
1430
+ pending: false
1431
+ })
1432
+ }
1433
+ status.value = 'streaming'
1434
+ return
1435
+ }
976
1436
  seedStreamingMessage(params.item)
977
1437
  status.value = 'streaming'
978
1438
  return
@@ -982,14 +1442,17 @@ const applyNotification = (notification: CodexRpcNotification) => {
982
1442
  if (params.item.type === 'collabAgentToolCall') {
983
1443
  applySubagentActivityItem(params.item)
984
1444
  }
985
- if (params.item.type === 'userMessage') {
986
- stripOptimisticDraftMessages()
987
- }
988
1445
  for (const nextMessage of itemToMessages(params.item)) {
989
- messages.value = upsertStreamingMessage(messages.value, {
1446
+ const confirmedMessage = {
990
1447
  ...nextMessage,
991
1448
  pending: false
992
- })
1449
+ }
1450
+ if (params.item.type === 'userMessage') {
1451
+ reconcilePendingUserMessage(confirmedMessage)
1452
+ continue
1453
+ }
1454
+
1455
+ messages.value = upsertStreamingMessage(messages.value, confirmedMessage)
993
1456
  }
994
1457
  return
995
1458
  }
@@ -1120,11 +1583,32 @@ const applyNotification = (notification: CodexRpcNotification) => {
1120
1583
  status.value = 'streaming'
1121
1584
  return
1122
1585
  }
1586
+ case 'thread/tokenUsage/updated': {
1587
+ const nextUsage = normalizeThreadTokenUsage(notification.params)
1588
+ if (nextUsage) {
1589
+ tokenUsage.value = nextUsage
1590
+ if (nextUsage.modelContextWindow != null) {
1591
+ modelContextWindow.value = nextUsage.modelContextWindow
1592
+ }
1593
+ }
1594
+ return
1595
+ }
1596
+ case 'error': {
1597
+ const params = notification.params as { error?: { message?: string } }
1598
+ const messageText = params.error?.message ?? 'The stream failed.'
1599
+ pushEventMessage('stream.error', messageText)
1600
+ clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
1601
+ clearLiveStream(new Error(messageText))
1602
+ error.value = messageText
1603
+ status.value = 'error'
1604
+ return
1605
+ }
1123
1606
  case 'turn/failed': {
1124
1607
  const params = notification.params as { error?: { message?: string } }
1125
1608
  const messageText = params.error?.message ?? 'The turn failed.'
1126
1609
  pushEventMessage('turn.failed', messageText)
1127
- clearLiveStream()
1610
+ clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
1611
+ clearLiveStream(new Error(messageText))
1128
1612
  error.value = messageText
1129
1613
  status.value = 'error'
1130
1614
  return
@@ -1133,12 +1617,15 @@ const applyNotification = (notification: CodexRpcNotification) => {
1133
1617
  const params = notification.params as { message?: string }
1134
1618
  const messageText = params.message ?? 'The stream failed.'
1135
1619
  pushEventMessage('stream.error', messageText)
1136
- clearLiveStream()
1620
+ clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
1621
+ clearLiveStream(new Error(messageText))
1137
1622
  error.value = messageText
1138
1623
  status.value = 'error'
1139
1624
  return
1140
1625
  }
1141
1626
  case 'turn/completed': {
1627
+ clearPendingOptimisticMessages(liveStream, { discardSnapshots: true })
1628
+ error.value = null
1142
1629
  status.value = 'ready'
1143
1630
  clearLiveStream()
1144
1631
  return
@@ -1150,165 +1637,126 @@ const applyNotification = (notification: CodexRpcNotification) => {
1150
1637
 
1151
1638
  const sendMessage = async () => {
1152
1639
  const text = input.value.trim()
1153
- if (!text) {
1640
+ const submittedAttachments = attachments.value.slice()
1641
+
1642
+ if (!text && submittedAttachments.length === 0) {
1643
+ return
1644
+ }
1645
+
1646
+ try {
1647
+ await loadPromptControls()
1648
+ } catch (caughtError) {
1649
+ error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
1650
+ status.value = 'error'
1154
1651
  return
1155
1652
  }
1156
1653
 
1157
1654
  pinnedToBottom.value = true
1158
1655
  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]
1656
+ attachmentError.value = null
1657
+ const submissionMethod = resolveTurnSubmissionMethod(hasActiveTurnEngagement())
1658
+ if (submissionMethod === 'turn/start') {
1659
+ status.value = 'submitted'
1175
1660
  }
1176
- let unsubscribe: (() => void) | null = null
1661
+ input.value = ''
1662
+ clearAttachments({ revoke: false })
1663
+ const optimisticMessage = buildOptimisticMessage(text, submittedAttachments)
1664
+ const optimisticMessageId = optimisticMessage.id
1665
+ rememberOptimisticAttachments(optimisticMessageId, submittedAttachments)
1666
+ messages.value = [...messages.value, optimisticMessage]
1667
+ let startedLiveStream: LiveStream | null = null
1177
1668
 
1178
1669
  try {
1179
- await ensureProjectRuntime()
1180
- const { threadId, created } = await ensureThread()
1181
1670
  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
1671
 
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
-
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
1672
+ if (submissionMethod === 'turn/steer') {
1673
+ const liveStream = await ensurePendingLiveStream()
1674
+ const uploadedAttachments = await uploadAttachments(liveStream.threadId, submittedAttachments)
1675
+ liveStream.pendingUserMessageIds.push(optimisticMessageId)
1676
+ const turnId = await waitForLiveStreamTurnId(liveStream)
1677
+
1678
+ await client.request<TurnStartResponse>('turn/steer', {
1679
+ threadId: liveStream.threadId,
1680
+ expectedTurnId: turnId,
1681
+ input: buildTurnStartInput(text, uploadedAttachments),
1682
+ ...buildTurnOverrides(selectedModel.value, selectedEffort.value)
1683
+ })
1684
+ return
1234
1685
  }
1235
1686
 
1236
- clearLiveStream()
1687
+ const liveStream = await ensurePendingLiveStream()
1688
+ startedLiveStream = liveStream
1689
+ const threadId = liveStream.threadId
1690
+ const uploadedAttachments = await uploadAttachments(threadId, submittedAttachments)
1691
+ liveStream.pendingUserMessageIds.push(optimisticMessageId)
1237
1692
 
1238
- const completion = new Promise<void>((resolve, reject) => {
1239
- resolveCompletion = resolve
1240
- rejectCompletion = (caughtError: Error) => reject(caughtError)
1241
- })
1242
-
1243
- const liveStream = {
1693
+ const turnStart = await client.request<TurnStartResponse>('turn/start', {
1244
1694
  threadId,
1245
- turnId: null as string | null,
1246
- bufferedNotifications: buffered,
1247
- observedSubagentThreadIds: new Set<string>(),
1248
- unsubscribe: null as (() => void) | null
1249
- }
1250
-
1251
- unsubscribe = client.subscribe((notification) => {
1252
- const targetThreadId = notificationThreadId(notification)
1253
- if (!targetThreadId) {
1254
- return
1255
- }
1695
+ input: buildTurnStartInput(text, uploadedAttachments),
1696
+ cwd: selectedProject.value?.projectPath ?? null,
1697
+ approvalPolicy: 'never',
1698
+ ...buildTurnOverrides(selectedModel.value, selectedEffort.value)
1699
+ })
1256
1700
 
1257
- if (targetThreadId !== threadId) {
1258
- if (liveStream.observedSubagentThreadIds.has(targetThreadId)) {
1259
- applySubagentNotification(targetThreadId, notification)
1260
- }
1261
- return
1262
- }
1701
+ setLiveStreamTurnId(liveStream, turnStart.turn.id)
1263
1702
 
1264
- if (!currentTurnId) {
1265
- buffered.push(notification)
1266
- return
1703
+ for (const notification of liveStream.bufferedNotifications.splice(0, liveStream.bufferedNotifications.length)) {
1704
+ const turnId = notificationTurnId(notification)
1705
+ if (turnId && turnId !== liveStream.turnId) {
1706
+ continue
1267
1707
  }
1268
1708
 
1269
- settleNotification(notification)
1270
- })
1271
- liveStream.unsubscribe = unsubscribe
1272
- session.liveStream = liveStream
1273
-
1274
- if (created && !routeThreadId.value) {
1275
- pendingThreadId.value = threadId
1709
+ applyNotification(notification)
1276
1710
  }
1711
+ } catch (caughtError) {
1712
+ const messageText = caughtError instanceof Error ? caughtError.message : String(caughtError)
1277
1713
 
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
1714
+ untrackPendingUserMessage(optimisticMessageId)
1715
+ removeOptimisticMessage(optimisticMessageId)
1291
1716
 
1292
- for (const notification of buffered.splice(0, buffered.length)) {
1293
- if (settleNotification(notification) && completionResolved) {
1294
- break
1717
+ if (submissionMethod === 'turn/start') {
1718
+ if (startedLiveStream && session.liveStream === startedLiveStream) {
1719
+ clearPendingOptimisticMessages(clearLiveStream(new Error(messageText)))
1295
1720
  }
1721
+ session.pendingLiveStream = null
1722
+ restoreDraftIfPristine(text, submittedAttachments)
1723
+ error.value = messageText
1724
+ status.value = 'error'
1725
+ return
1296
1726
  }
1297
1727
 
1298
- await completion
1299
- status.value = 'ready'
1728
+ restoreDraftIfPristine(text, submittedAttachments)
1729
+ error.value = messageText
1730
+ }
1731
+ }
1732
+
1733
+ const stopActiveTurn = async () => {
1734
+ if (!hasActiveTurnEngagement()) {
1735
+ return
1736
+ }
1737
+
1738
+ let liveStream: LiveStream | null = null
1300
1739
 
1301
- if (routeThreadId.value) {
1302
- await hydrateThread(threadId)
1740
+ try {
1741
+ liveStream = await ensurePendingLiveStream()
1742
+ if (liveStream.interruptRequested) {
1303
1743
  return
1304
1744
  }
1745
+
1746
+ setLiveStreamInterruptRequested(liveStream, true)
1747
+ error.value = null
1748
+ const client = getClient(props.projectId)
1749
+ const turnId = await waitForLiveStreamTurnId(liveStream)
1750
+ await client.request('turn/interrupt', {
1751
+ threadId: liveStream.threadId,
1752
+ turnId
1753
+ })
1754
+ liveStream.interruptAcknowledged = true
1305
1755
  } catch (caughtError) {
1306
- unsubscribe?.()
1307
- if (session.liveStream?.unsubscribe === unsubscribe) {
1308
- session.liveStream = null
1756
+ if (liveStream) {
1757
+ setLiveStreamInterruptRequested(liveStream, false)
1309
1758
  }
1310
1759
  error.value = caughtError instanceof Error ? caughtError.message : String(caughtError)
1311
- status.value = 'error'
1312
1760
  }
1313
1761
  }
1314
1762
 
@@ -1335,6 +1783,7 @@ onMounted(() => {
1335
1783
  void refreshProjects()
1336
1784
  }
1337
1785
 
1786
+ void loadPromptControls()
1338
1787
  void scheduleScrollToBottom('auto')
1339
1788
  })
1340
1789
 
@@ -1388,6 +1837,18 @@ watch(status, (nextStatus, previousStatus) => {
1388
1837
 
1389
1838
  void scheduleScrollToBottom(nextStatus === 'streaming' ? 'auto' : 'smooth')
1390
1839
  }, { flush: 'post' })
1840
+
1841
+ watch([selectedModel, availableModels], () => {
1842
+ const nextSelection = coercePromptSelection(effectiveModelList.value, selectedModel.value, selectedEffort.value)
1843
+ if (selectedModel.value !== nextSelection.model) {
1844
+ selectedModel.value = nextSelection.model
1845
+ return
1846
+ }
1847
+
1848
+ if (selectedEffort.value !== nextSelection.effort) {
1849
+ selectedEffort.value = nextSelection.effort
1850
+ }
1851
+ }, { flush: 'sync' })
1391
1852
  </script>
1392
1853
 
1393
1854
  <template>
@@ -1461,7 +1922,10 @@ watch(status, (nextStatus, previousStatus) => {
1461
1922
  compact
1462
1923
  >
1463
1924
  <template #content="{ message }">
1464
- <MessageContent :message="message as ChatMessage" />
1925
+ <MessageContent
1926
+ :message="message as ChatMessage"
1927
+ :project-id="projectId"
1928
+ />
1465
1929
  </template>
1466
1930
  </UChatMessages>
1467
1931
  </div>
@@ -1469,30 +1933,261 @@ watch(status, (nextStatus, previousStatus) => {
1469
1933
  <div class="sticky bottom-0 shrink-0 border-t border-default bg-default/95 px-4 py-3 backdrop-blur md:px-6">
1470
1934
  <div class="mx-auto w-full max-w-5xl">
1471
1935
  <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"
1936
+ <UAlert
1937
+ v-if="composerError"
1938
+ color="error"
1939
+ variant="soft"
1940
+ icon="i-lucide-circle-alert"
1941
+ :title="composerError"
1942
+ class="mb-3"
1494
1943
  />
1495
- </UChatPrompt>
1944
+
1945
+ <div
1946
+ class="relative"
1947
+ @dragenter="onDragEnter"
1948
+ @dragleave="onDragLeave"
1949
+ @dragover="onDragOver"
1950
+ @drop="onDrop"
1951
+ >
1952
+ <div
1953
+ v-if="isDragging"
1954
+ 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"
1955
+ >
1956
+ Drop images to attach them
1957
+ </div>
1958
+
1959
+ <UChatPrompt
1960
+ v-model="input"
1961
+ placeholder="Describe the change you want Codex to make"
1962
+ :error="submitError"
1963
+ :disabled="isComposerDisabled"
1964
+ autoresize
1965
+ @submit.prevent="sendMessage"
1966
+ @keydown.enter="onPromptEnter"
1967
+ @compositionstart="onCompositionStart"
1968
+ @compositionend="onCompositionEnd"
1969
+ @paste="onPaste"
1970
+ >
1971
+ <template #header>
1972
+ <div
1973
+ v-if="attachments.length"
1974
+ class="flex flex-wrap gap-2 pb-2"
1975
+ >
1976
+ <div
1977
+ v-for="attachment in attachments"
1978
+ :key="attachment.id"
1979
+ class="flex max-w-full items-center gap-2 rounded-2xl border border-default bg-elevated/35 px-2 py-1.5"
1980
+ >
1981
+ <img
1982
+ :src="attachment.previewUrl"
1983
+ :alt="attachment.name"
1984
+ class="size-10 rounded-xl object-cover"
1985
+ >
1986
+ <div class="min-w-0">
1987
+ <div class="max-w-40 truncate text-xs font-medium text-highlighted">
1988
+ {{ attachment.name }}
1989
+ </div>
1990
+ <div class="text-[11px] text-muted">
1991
+ {{ formatAttachmentSize(attachment.size) }}
1992
+ </div>
1993
+ </div>
1994
+ <UButton
1995
+ type="button"
1996
+ color="neutral"
1997
+ variant="ghost"
1998
+ size="xs"
1999
+ icon="i-lucide-x"
2000
+ :disabled="isComposerDisabled"
2001
+ class="rounded-full"
2002
+ :aria-label="`Remove ${attachment.name}`"
2003
+ @click="removeAttachment(attachment.id)"
2004
+ />
2005
+ </div>
2006
+ </div>
2007
+ </template>
2008
+
2009
+ <UChatPromptSubmit
2010
+ :status="promptSubmitStatus"
2011
+ @stop="stopActiveTurn"
2012
+ />
2013
+
2014
+ <template #footer>
2015
+ <input
2016
+ ref="fileInput"
2017
+ type="file"
2018
+ accept="image/*"
2019
+ class="hidden"
2020
+ :disabled="isComposerDisabled"
2021
+ @change="onFileInputChange"
2022
+ >
2023
+
2024
+ <div class="flex w-full flex-wrap items-center gap-2 pt-1">
2025
+ <div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
2026
+ <UButton
2027
+ type="button"
2028
+ color="neutral"
2029
+ variant="ghost"
2030
+ size="sm"
2031
+ icon="i-lucide-plus"
2032
+ :disabled="isComposerDisabled"
2033
+ class="size-8 shrink-0 justify-center rounded-full border border-default/70"
2034
+ :ui="{ leadingIcon: 'size-4', base: 'px-0' }"
2035
+ aria-label="Attach image"
2036
+ @click="openFilePicker"
2037
+ />
2038
+
2039
+ <USelect
2040
+ v-model="selectedModel"
2041
+ :items="modelSelectItems"
2042
+ color="neutral"
2043
+ variant="ghost"
2044
+ size="sm"
2045
+ :loading="promptControlsLoading"
2046
+ :disabled="isComposerDisabled"
2047
+ class="min-w-0 flex-1 sm:max-w-52 sm:flex-none"
2048
+ :ui="{ base: 'rounded-full border border-default/70 bg-default/70', value: 'truncate', content: 'min-w-56' }"
2049
+ />
2050
+
2051
+ <USelect
2052
+ v-model="selectedEffort"
2053
+ :items="effortSelectItems"
2054
+ color="neutral"
2055
+ variant="ghost"
2056
+ size="sm"
2057
+ :disabled="isComposerDisabled"
2058
+ class="min-w-0 flex-1 sm:max-w-36 sm:flex-none"
2059
+ :ui="{ base: 'rounded-full border border-default/70 bg-default/70', value: 'truncate' }"
2060
+ />
2061
+ </div>
2062
+
2063
+ <div class="ml-auto flex shrink-0 items-center">
2064
+ <UPopover
2065
+ :content="{ side: 'top', align: 'end' }"
2066
+ arrow
2067
+ >
2068
+ <button
2069
+ type="button"
2070
+ class="flex h-8 shrink-0 items-center gap-2 px-0 text-left text-muted"
2071
+ >
2072
+ <span class="relative flex size-7 items-center justify-center">
2073
+ <svg
2074
+ viewBox="0 0 36 36"
2075
+ class="size-7 -rotate-90"
2076
+ aria-hidden="true"
2077
+ >
2078
+ <circle
2079
+ cx="18"
2080
+ cy="18"
2081
+ r="15.5"
2082
+ fill="none"
2083
+ stroke-width="3"
2084
+ pathLength="100"
2085
+ class="stroke-current text-muted/20"
2086
+ stroke-dasharray="100 100"
2087
+ />
2088
+ <circle
2089
+ cx="18"
2090
+ cy="18"
2091
+ r="15.5"
2092
+ fill="none"
2093
+ stroke-width="3"
2094
+ pathLength="100"
2095
+ class="stroke-current text-primary"
2096
+ stroke-linecap="round"
2097
+ :stroke-dasharray="`${contextUsedPercent} 100`"
2098
+ />
2099
+ </svg>
2100
+ <span class="absolute text-[9px] font-semibold text-highlighted">
2101
+ {{ contextIndicatorLabel }}
2102
+ </span>
2103
+ </span>
2104
+
2105
+ <span class="text-[11px] leading-none text-muted">context window</span>
2106
+ </button>
2107
+
2108
+ <template #content>
2109
+ <div class="w-72 space-y-3 p-4">
2110
+ <div class="space-y-1">
2111
+ <div class="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
2112
+ Context Window
2113
+ </div>
2114
+ <div class="text-sm font-medium text-highlighted">
2115
+ {{ selectedModelOption?.displayName ?? 'Selected model' }}
2116
+ </div>
2117
+ </div>
2118
+
2119
+ <div
2120
+ v-if="contextWindowState.contextWindow && contextWindowState.usedTokens !== null"
2121
+ class="grid grid-cols-2 gap-3 text-sm"
2122
+ >
2123
+ <div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
2124
+ <div class="text-[11px] uppercase tracking-[0.18em] text-muted">
2125
+ Remaining
2126
+ </div>
2127
+ <div class="mt-1 font-semibold text-highlighted">
2128
+ {{ Math.round(contextWindowState.remainingPercent ?? 0) }}%
2129
+ </div>
2130
+ <div class="text-xs text-muted">
2131
+ {{ formatCompactTokenCount(contextWindowState.remainingTokens ?? 0) }} tokens
2132
+ </div>
2133
+ </div>
2134
+ <div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
2135
+ <div class="text-[11px] uppercase tracking-[0.18em] text-muted">
2136
+ Used
2137
+ </div>
2138
+ <div class="mt-1 font-semibold text-highlighted">
2139
+ {{ formatCompactTokenCount(contextWindowState.usedTokens ?? 0) }}
2140
+ </div>
2141
+ <div class="text-xs text-muted">
2142
+ of {{ formatCompactTokenCount(contextWindowState.contextWindow) }}
2143
+ </div>
2144
+ </div>
2145
+ </div>
2146
+
2147
+ <div
2148
+ v-else
2149
+ class="rounded-2xl border border-default bg-elevated/35 px-3 py-2 text-sm text-muted"
2150
+ >
2151
+ <div v-if="contextWindowState.contextWindow">
2152
+ Live token usage will appear after the next turn completes.
2153
+ </div>
2154
+ <div v-else>
2155
+ Context window details are not available from the runtime yet.
2156
+ </div>
2157
+ </div>
2158
+
2159
+ <div class="grid grid-cols-2 gap-3 text-sm">
2160
+ <div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
2161
+ <div class="text-[11px] uppercase tracking-[0.18em] text-muted">
2162
+ Input
2163
+ </div>
2164
+ <div class="mt-1 font-semibold text-highlighted">
2165
+ {{ formatCompactTokenCount(tokenUsage?.totalInputTokens ?? 0) }}
2166
+ </div>
2167
+ <div class="text-xs text-muted">
2168
+ cached {{ formatCompactTokenCount(tokenUsage?.totalCachedInputTokens ?? 0) }}
2169
+ </div>
2170
+ </div>
2171
+ <div class="rounded-2xl border border-default bg-elevated/35 px-3 py-2">
2172
+ <div class="text-[11px] uppercase tracking-[0.18em] text-muted">
2173
+ Output
2174
+ </div>
2175
+ <div class="mt-1 font-semibold text-highlighted">
2176
+ {{ formatCompactTokenCount(tokenUsage?.totalOutputTokens ?? 0) }}
2177
+ </div>
2178
+ <div class="text-xs text-muted">
2179
+ effort {{ formatReasoningEffortLabel(selectedEffort) }}
2180
+ </div>
2181
+ </div>
2182
+ </div>
2183
+ </div>
2184
+ </template>
2185
+ </UPopover>
2186
+ </div>
2187
+ </div>
2188
+ </template>
2189
+ </UChatPrompt>
2190
+ </div>
1496
2191
  </div>
1497
2192
  </div>
1498
2193
  </section>