Skip to content
Binary file added .github/pr-assets/pr9-live-order-fix.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
134 changes: 133 additions & 1 deletion src/api/normalizers/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
Turn,
UserInput,
} from '../appServerDtos'
import type { CommandExecutionData, UiFileAttachment, UiMessage, UiProjectGroup, UiThread } from '../../types/codex'
import type { CommandExecutionData, UiFileAttachment, UiFileChangeData, UiMessage, UiProjectGroup, UiThread } from '../../types/codex'

function toIso(seconds: number): string {
return new Date(seconds * 1000).toISOString()
Expand Down Expand Up @@ -160,6 +160,43 @@ function toUiMessages(item: ThreadItem): UiMessage[] {
]
}

if (item.type === 'fileChange') {
const raw = item as Record<string, unknown>
const status = normalizeFileChangeStatus(raw.status)
const changes = Array.isArray(raw.changes) ? raw.changes : []
const messages: UiMessage[] = []

for (const [index, change] of changes.entries()) {
if (!change || typeof change !== 'object' || Array.isArray(change)) continue
const row = change as Record<string, unknown>
const path = typeof row.path === 'string' ? row.path.trim() : ''
if (!path) continue

const diff = typeof row.diff === 'string' ? row.diff : ''
const { kind, movePath } = readFileChangeKind(row.kind)
const { linesAdded, linesRemoved, openLine } = readFileChangeDiffStats(diff)

messages.push({
id: `${item.id}:change:${index}`,
role: 'system',
text: path,
messageType: 'fileChange',
fileChange: {
path,
kind,
status,
diff,
movePath,
linesAdded,
linesRemoved,
openLine,
},
})
}

return messages
}

return []
}

Expand All @@ -169,6 +206,97 @@ function normalizeCommandStatus(value: unknown): CommandExecutionData['status']
return 'completed'
}

function normalizeFileChangeStatus(value: unknown): UiFileChangeData['status'] {
if (value === 'completed' || value === 'failed' || value === 'declined') {
return value
}
if (value === 'inProgress' || value === 'in_progress') return 'inProgress'
return 'completed'
}

function readFileChangeKind(value: unknown): Pick<UiFileChangeData, 'kind' | 'movePath'> {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const record = value as Record<string, unknown>
const type = record.type
if (type === 'add' || type === 'delete' || type === 'update') {
const movePath =
type === 'update' && typeof record.move_path === 'string' && record.move_path.trim().length > 0
? record.move_path.trim()
: null
return { kind: type, movePath }
}
}

return { kind: 'update', movePath: null }
}

function readFileChangeDiffStats(diff: string): Pick<UiFileChangeData, 'linesAdded' | 'linesRemoved' | 'openLine'> {
if (!diff.trim()) {
return {
linesAdded: 0,
linesRemoved: 0,
openLine: null,
}
}

let linesAdded = 0
let linesRemoved = 0
let openLine: number | null = null
let nextOldLine: number | null = null
let nextNewLine: number | null = null

for (const line of diff.split(/\r?\n/u)) {
const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/u)
if (hunkMatch) {
nextOldLine = Number(hunkMatch[1])
nextNewLine = Number(hunkMatch[2])
if (openLine === null) {
openLine = nextNewLine > 0 ? nextNewLine : nextOldLine
}
continue
}

if (
line.startsWith('diff --git ') ||
line.startsWith('index ') ||
line.startsWith('+++ ') ||
line.startsWith('--- ') ||
line.startsWith('Binary files ')
) {
continue
}

if (line.startsWith('+')) {
linesAdded += 1
if (openLine === null && nextNewLine !== null) {
openLine = nextNewLine
}
if (nextNewLine !== null) nextNewLine += 1
continue
}

if (line.startsWith('-')) {
linesRemoved += 1
if (openLine === null && nextOldLine !== null) {
openLine = nextOldLine
}
if (nextOldLine !== null) nextOldLine += 1
continue
}

if (line.startsWith(' ')) {
if (nextOldLine !== null) nextOldLine += 1
if (nextNewLine !== null) nextNewLine += 1
}
}

return {
linesAdded,
linesRemoved,
openLine,
}
}

function pickThreadName(summary: Thread): string {
const direct = [summary.preview]
for (const candidate of direct) {
Expand Down Expand Up @@ -265,6 +393,10 @@ export function normalizeThreadMessagesV2(payload: ThreadReadResponse): UiMessag
return messages
}

export function normalizeThreadItemV2(item: ThreadItem): UiMessage[] {
return toUiMessages(item)
}

export function readThreadInProgressFromResponse(payload: ThreadReadResponse): boolean {
const turns = Array.isArray(payload.thread.turns) ? payload.thread.turns : []
return isTurnInProgress(turns.at(-1))
Expand Down
29 changes: 23 additions & 6 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,22 @@ function isTermuxRuntime(): boolean {
return Boolean(process.env.TERMUX_VERSION || process.env.PREFIX?.includes('/com.termux/'))
}

function canRun(command: string, args: string[] = []): boolean {
const result = canRunCommand(command, args)
return result
function getNpmGlobalBinDir(prefix: string): string {
return process.platform === 'win32' ? prefix : join(prefix, 'bin')
}

function prependPathEntry(existingPath: string, entry: string): string {
const normalizedEntry = entry.trim()
if (!normalizedEntry) return existingPath

const separator = process.platform === 'win32' ? ';' : ':'
const parts = existingPath
.split(separator)
.map((value) => value.trim())
.filter(Boolean)
.filter((value) => value !== normalizedEntry)

return [normalizedEntry, ...parts].join(separator)
}

function runOrFail(command: string, args: string[], label: string): void {
Expand All @@ -69,11 +82,11 @@ function runWithStatus(command: string, args: string[]): number {
}

function resolveCloudflaredCommand(): string | null {
if (canRun('cloudflared', ['--version'])) {
if (canRunCommand('cloudflared', ['--version'])) {
return 'cloudflared'
}
const localCandidate = join(homedir(), '.local', 'bin', 'cloudflared')
if (existsSync(localCandidate) && canRun(localCandidate, ['--version'])) {
if (existsSync(localCandidate) && canRunCommand(localCandidate, ['--version'])) {
return localCandidate
}
return null
Expand Down Expand Up @@ -213,7 +226,7 @@ function ensureCodexInstalled(): string | null {
const userPrefix = getUserNpmPrefix()
console.log(`\nGlobal npm install requires elevated permissions. Retrying with --prefix ${userPrefix}...\n`)
runOrFail('npm', ['install', '-g', '--prefix', userPrefix, pkg], `${label} (user prefix)`)
process.env.PATH = `${join(userPrefix, 'bin')}:${process.env.PATH ?? ''}`
process.env.PATH = prependPathEntry(process.env.PATH ?? '', getNpmGlobalBinDir(userPrefix))
}

if (isTermuxRuntime()) {
Expand Down Expand Up @@ -448,6 +461,9 @@ async function startServer(options: { port: string; password: string | boolean;
}
}
const codexCommand = ensureCodexInstalled() ?? resolveCodexCommand()
if (codexCommand) {
process.env.CODEXUI_CODEX_COMMAND = codexCommand
}
if (!hasCodexAuth() && codexCommand) {
console.log('\nCodex is not logged in. Starting `codex login`...\n')
runOrFail(codexCommand, ['login'], 'Codex login')
Expand Down Expand Up @@ -535,6 +551,7 @@ async function startServer(options: { port: string; password: string | boolean;

async function runLogin() {
const codexCommand = ensureCodexInstalled() ?? 'codex'
process.env.CODEXUI_CODEX_COMMAND = codexCommand
console.log('\nStarting `codex login`...\n')
runOrFail(codexCommand, ['login'], 'Codex login')
}
Expand Down
Loading