From 688aa0a76d29de2ca67a61e31650d9dbc2c01c55 Mon Sep 17 00:00:00 2001 From: taobo Date: Thu, 2 Apr 2026 19:22:52 +0800 Subject: [PATCH 01/26] Fix mobile review pane scrolling --- src/components/content/ReviewPane.vue | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/content/ReviewPane.vue b/src/components/content/ReviewPane.vue index fe0ebcfe..d20dcae1 100644 --- a/src/components/content/ReviewPane.vue +++ b/src/components/content/ReviewPane.vue @@ -1210,7 +1210,7 @@ onBeforeUnmount(() => { .review-pane-content, .review-pane-findings { - @apply min-h-0 flex-1 overflow-hidden; + @apply min-h-0 flex-1 flex flex-col overflow-hidden; } .review-pane-bulk-actions { @@ -1334,7 +1334,8 @@ onBeforeUnmount(() => { } .review-pane-diff { - @apply min-h-0 overflow-y-auto px-3 py-3; + @apply min-h-0 flex-1 overflow-y-auto px-3 py-3; + -webkit-overflow-scrolling: touch; } .review-pane-file-header, @@ -1444,7 +1445,8 @@ onBeforeUnmount(() => { } .review-pane-findings-list { - @apply flex h-full flex-col gap-2.5 overflow-y-auto px-3 py-3; + @apply flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-3 py-3; + -webkit-overflow-scrolling: touch; } .review-pane-finding { @@ -1613,7 +1615,7 @@ onBeforeUnmount(() => { } .review-pane-main { - @apply block; + @apply flex h-full min-h-0 flex-col; } .review-pane-resizer { From d4a2b9b0958fe69fccc2a8395b1bac3fce01979b Mon Sep 17 00:00:00 2001 From: taobo Date: Sat, 4 Apr 2026 13:27:43 +0800 Subject: [PATCH 02/26] Match changed-files delta colors to review pane --- src/components/content/ThreadConversation.vue | 24 +++++++++++++++---- src/style.css | 12 ++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/components/content/ThreadConversation.vue b/src/components/content/ThreadConversation.vue index 0f78d471..113e0168 100644 --- a/src/components/content/ThreadConversation.vue +++ b/src/components/content/ThreadConversation.vue @@ -125,10 +125,10 @@ {{ fileChangeSummaryLabel(readStandaloneFileChangeSummary(message)) }} - {{ part.label }} @@ -539,10 +539,10 @@ {{ fileChangeSummaryLabel(readAnchoredFileChangeSummary(message)) }} - {{ part.label }} @@ -4422,6 +4422,22 @@ onBeforeUnmount(() => { @apply inline-flex items-center whitespace-nowrap; } +.file-change-summary-signed-count { + @apply rounded-full px-2 py-1; +} + +.file-change-summary-signed-count[data-tone='add'] { + @apply bg-emerald-100 text-emerald-700; +} + +.file-change-summary-signed-count[data-tone='remove'] { + @apply bg-rose-100 text-rose-700; +} + +.file-change-summary-signed-count[data-tone='neutral'] { + @apply bg-zinc-100 text-zinc-600; +} + .file-change-signed-count[data-tone='add'] { @apply text-emerald-600; } diff --git a/src/style.css b/src/style.css index bafa1216..df11c8ab 100644 --- a/src/style.css +++ b/src/style.css @@ -521,6 +521,18 @@ @apply bg-zinc-800 text-zinc-300; } +:root.dark .file-change-summary-signed-count[data-tone='add'] { + @apply bg-emerald-500/15 text-emerald-300; +} + +:root.dark .file-change-summary-signed-count[data-tone='remove'] { + @apply bg-rose-500/15 text-rose-300; +} + +:root.dark .file-change-summary-signed-count[data-tone='neutral'] { + @apply bg-zinc-800 text-zinc-300; +} + :root.dark .file-change-signed-count[data-tone='add'] { @apply text-emerald-300; } From 6b45c663ad24881c3321d32e3eecd42ef5d11409 Mon Sep 17 00:00:00 2001 From: taobo Date: Sat, 4 Apr 2026 13:30:39 +0800 Subject: [PATCH 03/26] Fix Skills Hub GitHub tree loading hang --- src/server/skillsRoutes.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/server/skillsRoutes.ts b/src/server/skillsRoutes.ts index d341c93f..39b44ccc 100644 --- a/src/server/skillsRoutes.ts +++ b/src/server/skillsRoutes.ts @@ -191,14 +191,35 @@ async function ghFetch(url: string): Promise { return fetch(url, { headers }) } +async function fetchGitHubJsonWithCurl(url: string): Promise { + const token = await getGhToken() + const args = [ + '-fsSL', + '--max-time', '20', + '-H', 'Accept: application/vnd.github+json', + '-H', 'User-Agent: codex-web-local', + ] + if (token) args.push('-H', `Authorization: Bearer ${token}`) + args.push(url) + const output = await runCommandWithOutput('curl', args) + return JSON.parse(output) as unknown +} + async function fetchSkillsTree(): Promise { if (skillsTreeCache && Date.now() - skillsTreeCache.fetchedAt < TREE_CACHE_TTL_MS) { return skillsTreeCache.entries } - const resp = await ghFetch(`https://api.github.com/repos/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/git/trees/main?recursive=1`) - if (!resp.ok) throw new Error(`GitHub tree API returned ${resp.status}`) - const data = (await resp.json()) as { tree?: Array<{ path: string; type: string }> } + const treeUrl = `https://api.github.com/repos/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/git/trees/main?recursive=1` + let data: { tree?: Array<{ path: string; type: string }> } + try { + // The Skills Hub tree is large; Node fetch can hang while reading the body in some proxy setups. + data = await fetchGitHubJsonWithCurl(treeUrl) as { tree?: Array<{ path: string; type: string }> } + } catch { + const resp = await ghFetch(treeUrl) + if (!resp.ok) throw new Error(`GitHub tree API returned ${resp.status}`) + data = (await resp.json()) as { tree?: Array<{ path: string; type: string }> } + } const metaPattern = /^skills\/([^/]+)\/([^/]+)\/_meta\.json$/ const seen = new Set() From 9f2c0a207c2f1dfdf89fadcb5982fc465e0f5e8c Mon Sep 17 00:00:00 2001 From: taobo Date: Sat, 4 Apr 2026 14:07:45 +0800 Subject: [PATCH 04/26] Release v1.0.17 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d0835184..050319a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nervmor/codexui", - "version": "1.0.16", + "version": "1.0.17", "description": "A lightweight web interface for Codex that runs on top of the Codex app-server, allowing remote access from any browser", "type": "module", "license": "MIT", From 91f8314ed832d085473c113dbda1f886374b3afb Mon Sep 17 00:00:00 2001 From: taobo Date: Sun, 12 Apr 2026 19:47:35 +0800 Subject: [PATCH 05/26] Sync active account state with runtime auth --- src/App.vue | 13 +++++-- src/server/accountRoutes.ts | 68 ++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/App.vue b/src/App.vue index 761cbd1f..b6036a99 100644 --- a/src/App.vue +++ b/src/App.vue @@ -326,7 +326,7 @@ import { searchThreads, switchAccount, } from './api/codexGateway' -import type { ReasoningEffort, ThreadScrollState, UiAccountEntry, UiRateLimitWindow } from './types/codex' +import type { ReasoningEffort, ThreadScrollState, UiAccountEntry, UiRateLimitSnapshot, UiRateLimitWindow } from './types/codex' const ThreadConversation = defineAsyncComponent(() => import('./components/content/ThreadConversation.vue')) const ReviewPane = defineAsyncComponent(() => import('./components/content/ReviewPane.vue')) @@ -712,12 +712,19 @@ function formatResetDateCompact(resetsAt: number | null): string { return `${date.getMonth() + 1}月${date.getDate()}日` } +function resolveDisplayedQuota(account: UiAccountEntry): UiRateLimitSnapshot | null { + if (account.isActive && codexQuota.value) { + return codexQuota.value + } + return account.quotaSnapshot +} + function formatAccountQuota(account: UiAccountEntry): string { if (isAccountUnavailable(account)) { return account.quotaError || '402 Payment Required' } - const quota = account.quotaSnapshot - const window = pickWeeklyQuotaWindow(account) + const quota = resolveDisplayedQuota(account) + const window = quota ? pickWeeklyQuotaWindow({ ...account, quotaSnapshot: quota }) : null if (window) { const remainingPercent = Math.max(0, Math.min(100, 100 - Math.round(window.usedPercent))) const refreshDate = formatResetDateCompact(window.resetsAt) diff --git a/src/server/accountRoutes.ts b/src/server/accountRoutes.ts index 1cda3c96..a0af4cae 100644 --- a/src/server/accountRoutes.ts +++ b/src/server/accountRoutes.ts @@ -693,6 +693,71 @@ async function importAccountFromAuthPath(path: string): Promise<{ } } +async function syncStoredActiveAccountWithAuth(): Promise { + const state = await readStoredAccountsState() + + let imported: Awaited> | null = null + try { + imported = await readAuthFileFromPath(getActiveAuthPath()) + } catch { + return state + } + + const snapshotPath = getSnapshotPath(toStorageId(imported.accountId)) + const existing = state.accounts.find((entry) => entry.accountId === imported.accountId) ?? null + const nextEntry: StoredAccountEntry = existing + ? { + ...existing, + storageId: toStorageId(imported.accountId), + authMode: imported.authMode, + email: imported.metadata.email ?? existing.email, + planType: imported.metadata.planType ?? existing.planType, + } + : { + accountId: imported.accountId, + storageId: toStorageId(imported.accountId), + authMode: imported.authMode, + email: imported.metadata.email, + planType: imported.metadata.planType, + lastRefreshedAtIso: new Date().toISOString(), + lastActivatedAtIso: null, + quotaSnapshot: null, + quotaUpdatedAtIso: null, + quotaStatus: 'idle', + quotaError: null, + unavailableReason: null, + } + + const shouldPersistState = ( + state.activeAccountId !== imported.accountId + || !existing + || existing.storageId !== nextEntry.storageId + || existing.authMode !== nextEntry.authMode + || existing.email !== nextEntry.email + || existing.planType !== nextEntry.planType + ) + + const hasSnapshot = await fileExists(snapshotPath) + if (!shouldPersistState && hasSnapshot) { + return state + } + + await writeSnapshot(nextEntry.storageId, imported.raw) + + const nextState = withUpsertedAccount({ + activeAccountId: imported.accountId, + accounts: state.accounts, + }, nextEntry) + await writeStoredAccountsState({ + activeAccountId: imported.accountId, + accounts: nextState.accounts, + }) + return { + activeAccountId: imported.accountId, + accounts: nextState.accounts, + } +} + export async function handleAccountRoutes( req: IncomingMessage, res: ServerResponse, @@ -702,6 +767,7 @@ export async function handleAccountRoutes( const { appServer } = context if (req.method === 'GET' && url.pathname === '/codex-api/accounts') { + await syncStoredActiveAccountWithAuth() const state = await scheduleAccountsBackgroundRefresh() setJson(res, 200, { data: { @@ -713,7 +779,7 @@ export async function handleAccountRoutes( } if (req.method === 'GET' && url.pathname === '/codex-api/accounts/active') { - const state = await readStoredAccountsState() + const state = await syncStoredActiveAccountWithAuth() const active = state.activeAccountId ? state.accounts.find((entry) => entry.accountId === state.activeAccountId) ?? null : null From 2912793a482215fad44451d966860127fb3ba5b7 Mon Sep 17 00:00:00 2001 From: taobo Date: Sun, 12 Apr 2026 19:54:01 +0800 Subject: [PATCH 06/26] Release v1.0.18 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 050319a5..4f026387 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nervmor/codexui", - "version": "1.0.17", + "version": "1.0.18", "description": "A lightweight web interface for Codex that runs on top of the Codex app-server, allowing remote access from any browser", "type": "module", "license": "MIT", From e5658d7566401d1671a33f5754cab854f937d668 Mon Sep 17 00:00:00 2001 From: taobo Date: Sun, 12 Apr 2026 21:52:29 +0800 Subject: [PATCH 07/26] Show Codex CLI version in settings --- src/App.vue | 14 ++++++- src/api/codexGateway.ts | 24 ++++++++++++ src/server/codexAppServerBridge.ts | 10 +++++ src/server/runtimeInfo.ts | 63 ++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/server/runtimeInfo.ts diff --git a/src/App.vue b/src/App.vue index b6036a99..5208e23a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -163,7 +163,7 @@ Settings - nervmor {{ worktreeName }} · v{{ appVersion }} + nervmor {{ worktreeName }} · v{{ appVersion }} (cli {{ codexCliVersion }}) @@ -319,6 +319,7 @@ import { getAccounts, getHomeDirectory, getProjectRootSuggestion, + getRuntimeInfo, getWorkspaceRootsState, openProjectRoot, removeAccount, @@ -407,6 +408,7 @@ const serverMatchedThreadIds = ref(null) let threadSearchTimer: ReturnType | null = null const defaultNewProjectName = ref('New Project (1)') const homeDirectory = ref('') +const codexCliVersion = ref('unknown') const isSettingsOpen = ref(false) const isReviewPaneOpen = ref(false) const accounts = ref([]) @@ -520,6 +522,7 @@ onMounted(() => { void initialize() void applyLaunchProjectPathFromUrl() void loadHomeDirectory() + void loadRuntimeInfo() void loadWorkspaceRootOptionsState() void refreshDefaultProjectName() }) @@ -1107,6 +1110,15 @@ async function loadHomeDirectory(): Promise { } } +async function loadRuntimeInfo(): Promise { + try { + const runtimeInfo = await getRuntimeInfo() + codexCliVersion.value = runtimeInfo.codexCliVersion || 'unknown' + } catch { + codexCliVersion.value = 'unknown' + } +} + async function loadWorkspaceRootOptionsState(): Promise { try { const state = await getWorkspaceRootsState() diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index 8cbbc2af..48afb638 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -69,6 +69,10 @@ export type WorkspaceRootsState = { active: string[] } +export type RuntimeInfo = { + codexCliVersion: string +} + export type ComposerFileSuggestion = { path: string } @@ -1184,6 +1188,26 @@ export async function getHomeDirectory(): Promise { return typeof data.path === 'string' ? data.path.trim() : '' } +export async function getRuntimeInfo(): Promise { + const response = await fetch('/codex-api/runtime-info') + const payload = (await response.json()) as unknown + if (!response.ok) { + throw new Error('Failed to load runtime info') + } + const record = + payload && typeof payload === 'object' && !Array.isArray(payload) + ? (payload as Record) + : {} + const data = + record.data && typeof record.data === 'object' && !Array.isArray(record.data) + ? (record.data as Record) + : {} + + return { + codexCliVersion: typeof data.codexCliVersion === 'string' ? data.codexCliVersion.trim() || 'unknown' : 'unknown', + } +} + export async function setWorkspaceRootsState(nextState: WorkspaceRootsState): Promise { const response = await fetch('/codex-api/workspace-roots-state', { method: 'PUT', diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index 8c6b4bfe..54b5a553 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -10,6 +10,7 @@ import { basename, isAbsolute, join, resolve } from 'node:path' import { writeFile } from 'node:fs/promises' import { handleAccountRoutes } from './accountRoutes.js' import { handleReviewRoutes } from './reviewGit.js' +import { readCodexCliVersion } from './runtimeInfo.js' import { handleSkillsRoutes, initializeSkillsSyncOnStartup } from './skillsRoutes.js' type JsonRpcCall = { @@ -1553,6 +1554,15 @@ export function createCodexBridgeMiddleware(): CodexBridgeMiddleware { return } + if (req.method === 'GET' && url.pathname === '/codex-api/runtime-info') { + setJson(res, 200, { + data: { + codexCliVersion: readCodexCliVersion(), + }, + }) + return + } + if (req.method === 'POST' && url.pathname === '/codex-api/worktree/create') { const payload = asRecord(await readJsonBody(req)) const rawSourceCwd = typeof payload?.sourceCwd === 'string' ? payload.sourceCwd.trim() : '' diff --git a/src/server/runtimeInfo.ts b/src/server/runtimeInfo.ts new file mode 100644 index 00000000..4244ebe5 --- /dev/null +++ b/src/server/runtimeInfo.ts @@ -0,0 +1,63 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +function getUserNpmPrefix(): string { + return join(homedir(), '.npm-global') +} + +function canRun(command: string, args: string[] = []): boolean { + const result = spawnSync(command, args, { stdio: 'ignore' }) + return result.status === 0 +} + +function resolveCodexCommand(): string | null { + if (canRun('codex', ['--version'])) { + return 'codex' + } + + const userCandidate = join(getUserNpmPrefix(), 'bin', 'codex') + if (existsSync(userCandidate) && canRun(userCandidate, ['--version'])) { + return userCandidate + } + + const prefix = process.env.PREFIX?.trim() + if (!prefix) { + return null + } + + const candidate = join(prefix, 'bin', 'codex') + if (existsSync(candidate) && canRun(candidate, ['--version'])) { + return candidate + } + + return null +} + +function extractCliVersion(output: string): string { + const trimmed = output.trim() + if (!trimmed) return 'unknown' + + const semverMatch = trimmed.match(/\bv?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/) + if (semverMatch?.[1]) { + return semverMatch[1] + } + + const tokens = trimmed.split(/\s+/) + return tokens[tokens.length - 1] ?? 'unknown' +} + +export function readCodexCliVersion(): string { + const command = resolveCodexCommand() + if (!command) { + return 'unknown' + } + + const result = spawnSync(command, ['--version'], { encoding: 'utf8' }) + if (result.status !== 0) { + return 'unknown' + } + + return extractCliVersion(`${result.stdout ?? ''}\n${result.stderr ?? ''}`) +} From 7d284faa48ecdb4fdaefdfbf2d7e96876f6d5bd2 Mon Sep 17 00:00:00 2001 From: taobo Date: Tue, 14 Apr 2026 18:45:37 +0800 Subject: [PATCH 08/26] Add native read-only file explorer --- .agents/skills/codex-app-parity/SKILL.md | 6 + src/App.vue | 26 +- src/api/codexGateway.ts | 84 ++++ src/components/content/FileExplorer.vue | 410 ++++++++++++++++++ src/components/content/ThreadConversation.vue | 22 +- src/composables/useFileExplorer.ts | 274 ++++++++++++ src/router/index.ts | 5 + src/utils/fileExplorer.ts | 183 ++++++++ 8 files changed, 1005 insertions(+), 5 deletions(-) create mode 100644 src/components/content/FileExplorer.vue create mode 100644 src/composables/useFileExplorer.ts create mode 100644 src/utils/fileExplorer.ts diff --git a/.agents/skills/codex-app-parity/SKILL.md b/.agents/skills/codex-app-parity/SKILL.md index c15ed17b..0b404efc 100644 --- a/.agents/skills/codex-app-parity/SKILL.md +++ b/.agents/skills/codex-app-parity/SKILL.md @@ -534,3 +534,9 @@ If a finding conflicts with current official docs or current official code, trea - A safer mobile fallback is: - hide the floating badge on mobile - expose the same version/worktree string inside the existing Settings panel instead of adding another mobile-only surface + +## Findings: App-Server Files UI Baseline (2026-04-14) + +- In Codex CLI `0.120.0`, `codex app-server generate-ts --experimental` exposes a stable read-only filesystem baseline for UI work: `fs/readDirectory`, `fs/getMetadata`, and `fs/readFile`, plus write/watch methods that can be layered later. +- The generated protocol is enough to implement an in-app file browser even when official public docs or official repo UI references do not provide a concrete desktop/web file-explorer spec. +- `fs/watch` is explicitly connection-scoped via `watchId`; for web clients that use stateless HTTP RPC plus a separate notification transport, treating watch support as a later phase is safer than coupling it into an MVP explorer. diff --git a/src/App.vue b/src/App.vue index 5208e23a..a78a7bd3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -200,6 +200,9 @@ +