@codori/client 0.0.7 → 0.0.9

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.
Files changed (39) hide show
  1. package/app/assets/css/main.css +22 -0
  2. package/app/components/BottomDrawerShell.vue +62 -0
  3. package/app/components/ChatWorkspace.vue +1009 -13
  4. package/app/components/LocalFileViewerModal.vue +314 -0
  5. package/app/components/MessagePartRenderer.ts +1 -0
  6. package/app/components/PendingUserRequestDrawer.vue +88 -0
  7. package/app/components/ProjectStatusDot.vue +1 -2
  8. package/app/components/ReviewStartDrawer.vue +186 -0
  9. package/app/components/SubagentTranscriptPanel.vue +5 -1
  10. package/app/components/UsageStatusModal.vue +265 -0
  11. package/app/components/VisualSubagentStack.vue +2 -0
  12. package/app/components/message-part/Event.vue +42 -8
  13. package/app/components/message-part/LocalFileLink.vue +51 -0
  14. package/app/components/message-part/ReviewPriorityBadge.vue +38 -0
  15. package/app/components/message-part/Text.vue +159 -10
  16. package/app/components/pending-request/McpElicitationForm.vue +264 -0
  17. package/app/components/pending-request/McpElicitationUrlPrompt.vue +100 -0
  18. package/app/components/pending-request/RequestUserInputForm.vue +235 -0
  19. package/app/composables/useChatSession.ts +1 -0
  20. package/app/composables/useLocalFileViewer.ts +46 -0
  21. package/app/composables/usePendingUserRequest.ts +124 -0
  22. package/app/composables/useThreadSummaries.ts +28 -2
  23. package/app/pages/index.vue +0 -1
  24. package/app/pages/projects/[...projectId]/threads/[threadId].vue +2 -0
  25. package/app/utils/chat-turn-engagement.ts +11 -2
  26. package/app/utils/review-priority-badge.ts +90 -0
  27. package/app/utils/slash-prompt-focus.ts +8 -0
  28. package/package.json +8 -1
  29. package/server/api/codori/projects/[projectId]/git/branches.get.ts +15 -0
  30. package/server/api/codori/projects/[projectId]/local-file.get.ts +44 -0
  31. package/shared/account-rate-limits.ts +190 -0
  32. package/shared/codex-chat.ts +72 -2
  33. package/shared/codex-rpc.ts +79 -3
  34. package/shared/codori.ts +20 -0
  35. package/shared/file-autocomplete.ts +166 -0
  36. package/shared/file-highlighting.ts +127 -0
  37. package/shared/local-files.ts +122 -0
  38. package/shared/pending-user-request.ts +374 -0
  39. package/shared/slash-commands.ts +97 -0
@@ -0,0 +1,124 @@
1
+ import { computed, ref, type Ref } from 'vue'
2
+ import type { CodexRpcServerRequest } from '../../shared/codex-rpc'
3
+ import {
4
+ buildPendingUserRequestDismissResponse,
5
+ parsePendingUserRequest,
6
+ type PendingUserRequest
7
+ } from '../../shared/pending-user-request'
8
+
9
+ type PendingUserRequestQueueEntry = {
10
+ request: PendingUserRequest
11
+ resolve: (value: unknown) => void
12
+ reject: (error: unknown) => void
13
+ }
14
+
15
+ type PendingUserRequestSession = {
16
+ current: Ref<PendingUserRequest | null>
17
+ queue: PendingUserRequestQueueEntry[]
18
+ }
19
+
20
+ const sessions = new Map<string, PendingUserRequestSession>()
21
+
22
+ const sessionKey = (projectId: string, threadId: string | null) =>
23
+ `${projectId}::${threadId ?? '__draft__'}`
24
+
25
+ const getSession = (projectId: string, threadId: string | null): PendingUserRequestSession => {
26
+ const key = sessionKey(projectId, threadId)
27
+ const existing = sessions.get(key)
28
+ if (existing) {
29
+ return existing
30
+ }
31
+
32
+ const session: PendingUserRequestSession = {
33
+ current: ref<PendingUserRequest | null>(null),
34
+ queue: []
35
+ }
36
+ sessions.set(key, session)
37
+ return session
38
+ }
39
+
40
+ const promoteNextRequest = (session: PendingUserRequestSession) => {
41
+ session.current.value = session.queue[0]?.request ?? null
42
+ }
43
+
44
+ const resolveQueuedRequest = (session: PendingUserRequestSession, responseFactory: (request: PendingUserRequest) => unknown) => {
45
+ while (session.queue.length > 0) {
46
+ const entry = session.queue.shift()
47
+ if (!entry) {
48
+ continue
49
+ }
50
+
51
+ entry.resolve(responseFactory(entry.request))
52
+ }
53
+
54
+ session.current.value = null
55
+ }
56
+
57
+ export const usePendingUserRequest = (projectId: string, activeThreadId: Ref<string | null>) => {
58
+ const resolveSession = (threadId: string | null) => getSession(projectId, threadId)
59
+
60
+ const handleServerRequest = async (request: CodexRpcServerRequest) => {
61
+ const normalized = parsePendingUserRequest(request)
62
+ if (!normalized) {
63
+ return null
64
+ }
65
+
66
+ const session = resolveSession(normalized.threadId ?? activeThreadId.value)
67
+
68
+ return await new Promise((resolve, reject) => {
69
+ session.queue.push({
70
+ request: normalized,
71
+ resolve,
72
+ reject
73
+ })
74
+
75
+ if (!session.current.value) {
76
+ promoteNextRequest(session)
77
+ }
78
+ })
79
+ }
80
+
81
+ const resolveCurrentRequest = (response: unknown) => {
82
+ const session = resolveSession(activeThreadId.value)
83
+ const current = session.queue.shift()
84
+ if (!current) {
85
+ return
86
+ }
87
+
88
+ current.resolve(response)
89
+ promoteNextRequest(session)
90
+ }
91
+
92
+ const rejectCurrentRequest = (error: unknown) => {
93
+ const session = resolveSession(activeThreadId.value)
94
+ const current = session.queue.shift()
95
+ if (!current) {
96
+ return
97
+ }
98
+
99
+ current.reject(error)
100
+ promoteNextRequest(session)
101
+ }
102
+
103
+ const cancelAllPendingRequests = () => {
104
+ const projectSessionPrefix = `${projectId}::`
105
+
106
+ for (const [key, session] of sessions.entries()) {
107
+ if (!key.startsWith(projectSessionPrefix)) {
108
+ continue
109
+ }
110
+
111
+ resolveQueuedRequest(session, buildPendingUserRequestDismissResponse)
112
+ sessions.delete(key)
113
+ }
114
+ }
115
+
116
+ return {
117
+ pendingRequest: computed(() => resolveSession(activeThreadId.value).current.value),
118
+ hasPendingRequest: computed(() => resolveSession(activeThreadId.value).current.value !== null),
119
+ handleServerRequest,
120
+ resolveCurrentRequest,
121
+ rejectCurrentRequest,
122
+ cancelAllPendingRequests
123
+ }
124
+ }
@@ -23,8 +23,34 @@ type UseThreadSummariesResult = ThreadSummariesState & {
23
23
 
24
24
  const states = new Map<string, ThreadSummariesState>()
25
25
 
26
+ export const normalizeThreadTitleCandidate = (value: string | null | undefined) => {
27
+ const raw = value?.trim() ?? ''
28
+ if (!raw) {
29
+ return ''
30
+ }
31
+
32
+ const stripped = raw
33
+ .replace(/<\/?[a-z_]+>/gi, ' ')
34
+ .replace(/\s+/g, ' ')
35
+ .trim()
36
+
37
+ if (!stripped) {
38
+ return ''
39
+ }
40
+
41
+ if (
42
+ /<(?:user_action|context)>/i.test(raw)
43
+ || /user initiated a review task/i.test(stripped)
44
+ || /review output from reviewer mode/i.test(stripped)
45
+ ) {
46
+ return 'Code Review'
47
+ }
48
+
49
+ return stripped
50
+ }
51
+
26
52
  export const resolveThreadSummaryTitle = (thread: Pick<CodexThread, 'id' | 'name' | 'preview'>) => {
27
- const nextTitle = thread.name?.trim() || thread.preview.trim()
53
+ const nextTitle = normalizeThreadTitleCandidate(thread.name) || normalizeThreadTitleCandidate(thread.preview)
28
54
  return nextTitle || `Thread ${thread.id}`
29
55
  }
30
56
 
@@ -41,7 +67,7 @@ export const renameThreadSummary = (
41
67
  updatedAt?: number
42
68
  }
43
69
  ) => {
44
- const nextTitle = input.title.trim()
70
+ const nextTitle = normalizeThreadTitleCandidate(input.title)
45
71
  if (!nextTitle) {
46
72
  return threads
47
73
  }
@@ -53,7 +53,6 @@
53
53
  </p>
54
54
  </div>
55
55
  </div>
56
-
57
56
  </div>
58
57
  </div>
59
58
  </template>
@@ -376,6 +376,7 @@ watch(
376
376
  <template #body>
377
377
  <VisualSubagentStack
378
378
  :agents="availablePanels"
379
+ :project-id="projectId"
379
380
  class="h-full min-h-0"
380
381
  @expand="openExpandedSubagent"
381
382
  />
@@ -441,6 +442,7 @@ watch(
441
442
  <SubagentTranscriptPanel
442
443
  v-if="expandedSubagentPanel"
443
444
  :agent="expandedSubagentPanel"
445
+ :project-id="projectId"
444
446
  :accent="expandedSubagentAccent"
445
447
  scroll-scope="expanded"
446
448
  expanded
@@ -48,14 +48,23 @@ export const shouldRetrySteerWithTurnStart = (message: string) =>
48
48
 
49
49
  export const shouldApplyNotificationToCurrentTurn = (input: {
50
50
  liveStreamTurnId: string | null
51
+ lockedTurnId?: string | null
51
52
  notificationMethod: string
52
53
  notificationTurnId: string | null
53
54
  }) =>
54
- input.liveStreamTurnId === null
55
+ (input.lockedTurnId ?? input.liveStreamTurnId) === null
55
56
  || input.notificationTurnId === null
56
- || input.notificationTurnId === input.liveStreamTurnId
57
+ || input.notificationTurnId === (input.lockedTurnId ?? input.liveStreamTurnId)
57
58
  || input.notificationMethod === 'turn/started'
58
59
 
60
+ export const shouldAdvanceLiveStreamTurn = (input: {
61
+ lockedTurnId?: string | null
62
+ nextTurnId: string | null
63
+ }) =>
64
+ !input.lockedTurnId
65
+ || !input.nextTurnId
66
+ || input.nextTurnId === input.lockedTurnId
67
+
59
68
  export const resolvePromptSubmitStatus = (input: {
60
69
  status: PromptSubmitStatus
61
70
  hasDraftContent: boolean
@@ -0,0 +1,90 @@
1
+ import type { ComarkElement, ComarkNode, ComarkPlugin, ComarkTree } from '@comark/vue'
2
+
3
+ export const REVIEW_PRIORITY_BADGE_TAG = 'review-priority-badge'
4
+
5
+ const REVIEW_PRIORITY_PATTERN = /^P([1-3])$/i
6
+
7
+ const isElementNode = (node: ComarkNode): node is ComarkElement => {
8
+ return Array.isArray(node) && typeof node[0] === 'string'
9
+ }
10
+
11
+ const getPriorityLabel = (node: ComarkElement) => {
12
+ if (node[0] !== 'span') {
13
+ return null
14
+ }
15
+
16
+ const children = node.slice(2)
17
+
18
+ if (children.length !== 1 || typeof children[0] !== 'string') {
19
+ return null
20
+ }
21
+
22
+ const label = children[0].trim().toUpperCase()
23
+
24
+ return REVIEW_PRIORITY_PATTERN.test(label) ? label as `P${1 | 2 | 3}` : null
25
+ }
26
+
27
+ const createPriorityBadgeNode = (label: `P${1 | 2 | 3}`): ComarkElement => {
28
+ return [REVIEW_PRIORITY_BADGE_TAG, { priority: label.slice(1) }, label]
29
+ }
30
+
31
+ const replaceLeadingPriorityBadge = (children: ComarkNode[]) => {
32
+ const targetIndex = children.findIndex((child) => {
33
+ return typeof child !== 'string' || child.trim().length > 0
34
+ })
35
+
36
+ if (targetIndex === -1) {
37
+ return children
38
+ }
39
+
40
+ const target = children[targetIndex]
41
+
42
+ if (!target) {
43
+ return children
44
+ }
45
+
46
+ if (!isElementNode(target)) {
47
+ return children
48
+ }
49
+
50
+ const label = getPriorityLabel(target)
51
+
52
+ if (!label) {
53
+ return children
54
+ }
55
+
56
+ return children.map((child, index) => {
57
+ return index === targetIndex ? createPriorityBadgeNode(label) : child
58
+ })
59
+ }
60
+
61
+ const transformNode = (node: ComarkNode, parentTag?: string): ComarkNode => {
62
+ if (!isElementNode(node)) {
63
+ return node
64
+ }
65
+
66
+ const [tag, props, ...children] = node
67
+ let nextChildren = children.map(child => transformNode(child, tag))
68
+
69
+ if (tag === 'li' || (tag === 'p' && parentTag === 'li')) {
70
+ nextChildren = replaceLeadingPriorityBadge(nextChildren)
71
+ }
72
+
73
+ return [tag, props, ...nextChildren]
74
+ }
75
+
76
+ export const transformReviewPriorityBadges = (tree: ComarkTree): ComarkTree => {
77
+ return {
78
+ ...tree,
79
+ nodes: tree.nodes.map(node => transformNode(node))
80
+ }
81
+ }
82
+
83
+ export const reviewPriorityBadgePlugin = (): ComarkPlugin => {
84
+ return {
85
+ name: 'review-priority-badge',
86
+ post: (state) => {
87
+ state.tree = transformReviewPriorityBadges(state.tree)
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,8 @@
1
+ // Slash-command popups are rendered as inert overlays. This helper is only for
2
+ // the composer blur guard to recognize "still inside the slash popup" targets.
3
+ export const isFocusWithinContainer = (
4
+ target: EventTarget | null | undefined,
5
+ container: ParentNode | null | undefined
6
+ ) =>
7
+ target instanceof Node
8
+ && Boolean(container?.contains(target))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codori/client",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "private": false,
5
5
  "description": "Codori Nuxt dashboard for project browsing, Codex chat, and thread resume.",
6
6
  "type": "module",
@@ -41,10 +41,17 @@
41
41
  "@comark/vue": "^0.2.0",
42
42
  "@iconify-json/lucide": "^1.2.87",
43
43
  "@nuxt/ui": "^4.6.0",
44
+ "beautiful-mermaid": "^1.1.3",
45
+ "katex": "^0.16.45",
44
46
  "nuxt": "^4.3.0",
45
47
  "ofetch": "^1.4.1",
46
48
  "shiki": "^3.23.0",
47
49
  "tailwindcss": "^4.1.14",
48
50
  "vue": "^3.5.22"
51
+ },
52
+ "devDependencies": {
53
+ "@vitejs/plugin-vue": "^6.0.6",
54
+ "@vue/test-utils": "^2.4.6",
55
+ "jsdom": "^29.0.2"
49
56
  }
50
57
  }
@@ -0,0 +1,15 @@
1
+ import { defineEventHandler, getRouterParam } from 'h3'
2
+ import { encodeProjectIdSegment, type ProjectGitBranchesResponse } from '~~/shared/codori'
3
+ import { proxyServerRequest } from '../../../../../utils/server-proxy'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const projectId = getRouterParam(event, 'projectId')
7
+ if (!projectId) {
8
+ throw new Error('Missing project id.')
9
+ }
10
+
11
+ return await proxyServerRequest<ProjectGitBranchesResponse>(
12
+ event,
13
+ `/api/projects/${encodeProjectIdSegment(projectId)}/git/branches`
14
+ )
15
+ })
@@ -0,0 +1,44 @@
1
+ import {
2
+ createError,
3
+ defineEventHandler,
4
+ getQuery,
5
+ getRouterParam
6
+ } from 'h3'
7
+ import { encodeProjectIdSegment } from '~~/shared/codori'
8
+ import type { ProjectLocalFileResponse } from '~~/shared/local-files'
9
+ import { proxyServerRequest } from '../../../../utils/server-proxy'
10
+
11
+ export default defineEventHandler(async (event) => {
12
+ const projectId = getRouterParam(event, 'projectId')
13
+ if (!projectId) {
14
+ throw createError({
15
+ statusCode: 400,
16
+ statusMessage: 'Missing project id.'
17
+ })
18
+ }
19
+
20
+ const queryValue = getQuery(event).path
21
+ const path = typeof queryValue === 'string'
22
+ ? queryValue
23
+ : ''
24
+
25
+ if (!path) {
26
+ throw createError({
27
+ statusCode: 400,
28
+ statusMessage: 'Missing local file path.'
29
+ })
30
+ }
31
+
32
+ try {
33
+ return await proxyServerRequest<ProjectLocalFileResponse>(
34
+ event,
35
+ `/api/projects/${encodeProjectIdSegment(projectId)}/local-file?${new URLSearchParams({ path }).toString()}`
36
+ )
37
+ } catch (error) {
38
+ const details = error as { statusCode?: number, statusMessage?: string }
39
+ throw createError({
40
+ statusCode: details.statusCode ?? 500,
41
+ statusMessage: details.statusMessage ?? 'Local file preview failed.'
42
+ })
43
+ }
44
+ })
@@ -0,0 +1,190 @@
1
+ export type RateLimitWindow = {
2
+ usedPercent: number | null
3
+ resetsAt: string | null
4
+ windowDurationMins: number | null
5
+ }
6
+
7
+ export type RateLimitBucket = {
8
+ limitId: string
9
+ limitName: string | null
10
+ primary: RateLimitWindow | null
11
+ secondary: RateLimitWindow | null
12
+ }
13
+
14
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
15
+ typeof value === 'object' && value !== null && !Array.isArray(value)
16
+
17
+ const toFiniteNumber = (value: unknown) => {
18
+ if (typeof value === 'number' && Number.isFinite(value)) {
19
+ return value
20
+ }
21
+
22
+ if (typeof value === 'bigint') {
23
+ return Number(value)
24
+ }
25
+
26
+ if (typeof value === 'string' && value.trim()) {
27
+ const parsed = Number(value)
28
+ if (Number.isFinite(parsed)) {
29
+ return parsed
30
+ }
31
+ }
32
+
33
+ return null
34
+ }
35
+
36
+ const toPercent = (value: unknown) => {
37
+ const parsed = toFiniteNumber(value)
38
+ if (parsed == null) {
39
+ return null
40
+ }
41
+
42
+ const normalized = parsed > 0 && parsed <= 1 ? parsed * 100 : parsed
43
+ return Math.max(0, Math.min(100, normalized))
44
+ }
45
+
46
+ const toTimestamp = (value: unknown) => {
47
+ if (typeof value !== 'string' || !value.trim()) {
48
+ return null
49
+ }
50
+
51
+ return Number.isNaN(Date.parse(value)) ? null : value
52
+ }
53
+
54
+ const normalizeRateLimitWindow = (value: unknown): RateLimitWindow | null => {
55
+ const record = isObjectRecord(value) ? value : null
56
+ if (!record) {
57
+ return null
58
+ }
59
+
60
+ const usedPercent = toPercent(record.usedPercent)
61
+ const resetsAt = toTimestamp(record.resetsAt)
62
+ const windowDurationMins = toFiniteNumber(record.windowDurationMins)
63
+
64
+ if (usedPercent == null && resetsAt == null && windowDurationMins == null) {
65
+ return null
66
+ }
67
+
68
+ return {
69
+ usedPercent,
70
+ resetsAt,
71
+ windowDurationMins
72
+ }
73
+ }
74
+
75
+ const mergeRateLimitWindow = (
76
+ existing: RateLimitWindow | null,
77
+ incoming: RateLimitWindow | null
78
+ ): RateLimitWindow | null => {
79
+ if (!existing) {
80
+ return incoming
81
+ }
82
+
83
+ if (!incoming) {
84
+ return existing
85
+ }
86
+
87
+ return {
88
+ usedPercent: incoming.usedPercent ?? existing.usedPercent,
89
+ resetsAt: incoming.resetsAt ?? existing.resetsAt,
90
+ windowDurationMins: incoming.windowDurationMins ?? existing.windowDurationMins
91
+ }
92
+ }
93
+
94
+ const normalizeRateLimitBucket = (value: unknown): RateLimitBucket | null => {
95
+ const record = isObjectRecord(value) ? value : null
96
+ if (!record) {
97
+ return null
98
+ }
99
+
100
+ const limitId = typeof record.limitId === 'string' && record.limitId.trim()
101
+ ? record.limitId.trim()
102
+ : null
103
+ const limitName = typeof record.limitName === 'string' && record.limitName.trim()
104
+ ? record.limitName.trim()
105
+ : null
106
+
107
+ if (!limitId && !limitName) {
108
+ return null
109
+ }
110
+
111
+ const primary = normalizeRateLimitWindow(record.primary)
112
+ const secondary = normalizeRateLimitWindow(record.secondary)
113
+ if (!primary && !secondary) {
114
+ return null
115
+ }
116
+
117
+ return {
118
+ limitId: limitId ?? limitName ?? 'unknown',
119
+ limitName,
120
+ primary,
121
+ secondary
122
+ }
123
+ }
124
+
125
+ const collectRateLimitCandidates = (value: unknown) => {
126
+ const root = isObjectRecord(value) ? value : null
127
+ const rateLimits = Array.isArray(root?.rateLimits) ? root.rateLimits : []
128
+ const rateLimitsByLimitId = isObjectRecord(root?.rateLimitsByLimitId)
129
+ ? Object.values(root.rateLimitsByLimitId)
130
+ : []
131
+
132
+ if (rateLimits.length > 0 || rateLimitsByLimitId.length > 0) {
133
+ return [...rateLimits, ...rateLimitsByLimitId]
134
+ }
135
+
136
+ return Array.isArray(value) ? value : []
137
+ }
138
+
139
+ export const normalizeAccountRateLimits = (value: unknown): RateLimitBucket[] => {
140
+ const bucketsById = new Map<string, RateLimitBucket>()
141
+
142
+ for (const candidate of collectRateLimitCandidates(value)) {
143
+ const bucket = normalizeRateLimitBucket(candidate)
144
+ if (!bucket) {
145
+ continue
146
+ }
147
+
148
+ const existing = bucketsById.get(bucket.limitId)
149
+ if (!existing) {
150
+ bucketsById.set(bucket.limitId, bucket)
151
+ continue
152
+ }
153
+
154
+ bucketsById.set(bucket.limitId, {
155
+ ...existing,
156
+ limitName: existing.limitName ?? bucket.limitName,
157
+ primary: mergeRateLimitWindow(existing.primary, bucket.primary),
158
+ secondary: mergeRateLimitWindow(existing.secondary, bucket.secondary)
159
+ })
160
+ }
161
+
162
+ return [...bucketsById.values()].sort((left, right) => {
163
+ const leftLabel = (left.limitName ?? left.limitId).toLowerCase()
164
+ const rightLabel = (right.limitName ?? right.limitId).toLowerCase()
165
+ return leftLabel.localeCompare(rightLabel)
166
+ })
167
+ }
168
+
169
+ export const formatRateLimitWindowDuration = (value: number | null) => {
170
+ if (value == null || value <= 0) {
171
+ return null
172
+ }
173
+
174
+ if (value % (60 * 24 * 7) === 0) {
175
+ const weeks = value / (60 * 24 * 7)
176
+ return `${weeks}w window`
177
+ }
178
+
179
+ if (value % (60 * 24) === 0) {
180
+ const days = value / (60 * 24)
181
+ return `${days}d window`
182
+ }
183
+
184
+ if (value % 60 === 0) {
185
+ const hours = value / 60
186
+ return `${hours}h window`
187
+ }
188
+
189
+ return `${value}m window`
190
+ }
@@ -1,4 +1,4 @@
1
- import type { CodexThread, CodexThreadItem, CodexUserInput } from './codex-rpc'
1
+ import type { CodexThread, CodexThreadItem, CodexTurn, CodexUserInput } from './codex-rpc'
2
2
 
3
3
  export const EVENT_PART = 'data-thread-event' as const
4
4
  export const ITEM_PART = 'data-thread-item' as const
@@ -8,6 +8,13 @@ export type ThreadEventData =
8
8
  kind: 'thread.started' | 'thread.ended' | 'thread.title' | 'turn.started' | 'turn.completed'
9
9
  title?: string | null
10
10
  }
11
+ | {
12
+ kind: 'review.started'
13
+ summary: string | null
14
+ }
15
+ | {
16
+ kind: 'review.completed'
17
+ }
11
18
  | {
12
19
  kind: 'turn.failed'
13
20
  error: {
@@ -161,6 +168,29 @@ const userInputToParts = (input: CodexUserInput): ChatPart[] => {
161
168
  }]
162
169
  }
163
170
 
171
+ const getUserMessageText = (item: Extract<CodexThreadItem, { type: 'userMessage' }>) =>
172
+ item.content
173
+ .filter((input): input is Extract<CodexUserInput, { type: 'text' }> => input.type === 'text')
174
+ .map(input => input.text.trim())
175
+ .filter(Boolean)
176
+ .join('\n')
177
+
178
+ const shouldHideReviewBootstrapUserMessage = (
179
+ item: Extract<CodexThreadItem, { type: 'userMessage' }>,
180
+ turn: CodexTurn
181
+ ) => {
182
+ const reviewLifecycle = turn.items.find((candidate): candidate is Extract<CodexThreadItem, { type: 'enteredReviewMode' | 'exitedReviewMode' }> =>
183
+ (candidate.type === 'enteredReviewMode' || candidate.type === 'exitedReviewMode')
184
+ && candidate.id === item.id
185
+ )
186
+
187
+ if (!reviewLifecycle) {
188
+ return false
189
+ }
190
+
191
+ return getUserMessageText(item) === reviewLifecycle.review.trim()
192
+ }
193
+
164
194
  export const itemToMessages = (item: CodexThreadItem): ChatMessage[] => {
165
195
  switch (item.type) {
166
196
  case 'userMessage':
@@ -300,13 +330,53 @@ export const itemToMessages = (item: CodexThreadItem): ChatMessage[] => {
300
330
  }
301
331
  }]
302
332
  }]
333
+ case 'enteredReviewMode':
334
+ return [{
335
+ id: `${item.id}-review-started`,
336
+ role: 'system',
337
+ parts: [{
338
+ type: EVENT_PART,
339
+ data: {
340
+ kind: 'review.started',
341
+ summary: item.review.trim() || null
342
+ }
343
+ }]
344
+ }]
345
+ case 'exitedReviewMode': {
346
+ return [{
347
+ id: `${item.id}-review-completed`,
348
+ role: 'system',
349
+ parts: [{
350
+ type: EVENT_PART,
351
+ data: {
352
+ kind: 'review.completed'
353
+ }
354
+ }]
355
+ }, {
356
+ id: `${item.id}-review-output`,
357
+ role: 'assistant',
358
+ parts: [{
359
+ type: 'text',
360
+ text: item.review,
361
+ state: 'done'
362
+ }]
363
+ }]
364
+ }
303
365
  default:
304
366
  return []
305
367
  }
306
368
  }
307
369
 
308
370
  export const threadToMessages = (thread: CodexThread) =>
309
- thread.turns.flatMap(turn => turn.items.flatMap(item => itemToMessages(item)))
371
+ thread.turns.flatMap((turn) =>
372
+ turn.items.flatMap((item) => {
373
+ if (item.type === 'userMessage' && shouldHideReviewBootstrapUserMessage(item, turn)) {
374
+ return []
375
+ }
376
+
377
+ return itemToMessages(item)
378
+ })
379
+ )
310
380
 
311
381
  export const eventToMessage = (id: string, data: ThreadEventData): ChatMessage => ({
312
382
  id,