Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ import type { ProviderConditionSnapshot } from '@shared/types/providerConditions

export type SessionKind = 'claude' | 'codex' | 'terminal'

export type ManagerEvents = {
// WHY private: this is the EventEmitter event map for SessionManager.
// It's used internally to type `on()`/`off()`/`emit()` overloads (see
// the `SessionManager` interface below), but no external file imports
// the bare type. Keeping it module-private documents the API surface:
// callers consume the manager through the `SessionManager` interface,
// not via direct event-map type access.
type ManagerEvents = {
started: [{ sessionId: string; kind: SessionKind; projectDir?: string }]
'pty-data': [{ sessionId: string; data: string }]
/** Raw PTY bytes for an attached agent inline terminal. Emitted
Expand Down Expand Up @@ -87,7 +93,12 @@ export type ManagerEvents = {
exit: [{ sessionId: string; exitCode: number; signal?: number }]
}

export type SpawnOptions = {
// WHY private: SpawnOptions is the argument shape for the spawn()
// method on this class. No external file types its own spawn() wrapper
// in terms of this — IPC handlers convert from their JSON-RPC payload
// shapes inline. Privatizing prevents accidental cross-module coupling
// to a type that will keep evolving alongside spawn() internals.
type SpawnOptions = {
/** Which kind of session to spawn. Defaults to 'claude' so the
* pre-existing call sites keep working without a kind arg. */
kind?: SessionKind
Expand Down Expand Up @@ -115,7 +126,10 @@ export type SpawnOptions = {
recoverTmuxName?: string
}

export type SpawnResult = {
// WHY private: same reasoning as SpawnOptions — the IPC handler that
// returns this to the renderer flattens it into a JSON payload at the
// boundary, so external code never sees the named type.
type SpawnResult = {
sessionId: string
/** Set only when a tmux-backed terminal was spawned (or recovered).
* Renderer must persist this so a subsequent launch can recover
Expand Down
4 changes: 0 additions & 4 deletions src/main/window/mainWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ function pushTrafficLightInset(): void {
}
}

export function getMainWindow(): BrowserWindow | null {
return mainWindow
}

export function focusMainWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) return
if (mainWindow.isMinimized()) mainWindow.restore()
Expand Down
10 changes: 7 additions & 3 deletions src/providers/claude/runtime/claudeSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
ClaudeCodeHeadless,
createProxyServer,
spawnClaudeWithProxy,
terminalToMarkdown,
type CompactionState,
type ClaudeConditionSnapshot,
type JsonlEntry,
Expand All @@ -19,8 +18,13 @@ import {
type TrustDialogState,
} from 'claude-code-headless'

// Re-export terminalToMarkdown so existing callers keep working.
export { terminalToMarkdown }
// HISTORICAL: this file used to `export { terminalToMarkdown }` as a
// compat shim left over from when the function was inlined here before
// being moved into the headless package. The shim has zero callers in
// the repo today — consumers either import directly from
// claude-code-headless or from src/shared/runtime/ptyScreen — so the
// re-export is gone. If a future caller needs it, import it where it
// actually lives.

export type ClaudeSessionOptions = {
cwd?: string
Expand Down
13 changes: 9 additions & 4 deletions src/providers/codex/renderer/rows/CodexRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import { CodeRenderContext } from '@renderer/features/feed/ui/Feed'
import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow'
import { formatToolFilePath } from '@shared/paths/displayPath'
import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript'

function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null
}
import { asRecord } from '@shared/lib/asRecord'
// WHY the import switch matters here: the local copy this replaced
// did NOT exclude arrays — it returned `value as Record<...>` for
// any non-null object including arrays. The shared helper rejects
// arrays. Every call site below was using `.raw` / `.input` style
// property reads on what should always be a plain object, so the
// stricter check is a free safety improvement, not a regression.
// If a future caller wants array-as-record they should use a
// different helper or do the cast inline.

function textFromContent(content: unknown): string {
if (typeof content === 'string') return content
Expand Down
90 changes: 16 additions & 74 deletions src/providers/codex/types/codexTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,24 @@
// turn_context — per-turn metadata (token counts, timing)
// event_msg — UI/lifecycle events (user_message, etc.)
//
// We type these loosely (same philosophy as Claude's transcript.ts)
// because the on-disk format is large and we only render a subset.
// Use the type guards at runtime; don't trust the discriminator alone.
// HISTORICAL: this file used to export named payload types
// (CodexSessionMeta, CodexResponseItem, CodexEventMsg) and a guard
// (isCodexConversationEntry). They had zero importers in the renderer
// or main — code that touches a rollout line either narrows the
// `payload: unknown` inline at the call site or treats it as
// `Record<string, unknown>` and probes for the fields it needs. The
// named types went stale relative to those call sites, so we deleted
// them rather than keep two parallel shapes that drifted apart. If a
// caller ever needs strongly-typed payloads again, derive them at the
// consumer.
//
// We type the envelope loosely (same philosophy as Claude's
// transcript.ts) because the on-disk format is large and we only
// render a subset. Use the type guards at runtime; don't trust the
// discriminator alone.
//
// Lives under src/core/types/ so main, renderer, and testbench can all
// import. Pure types + guards — no runtime, no Node, no DOM.
// import. Pure types — no runtime, no Node, no DOM.

/**
* One line of a codex rollout JSONL file. The envelope carries a
Expand All @@ -29,73 +41,3 @@ export type CodexRolloutLine = {
type: string
payload: unknown
}

/**
* The session_meta payload — first line of every rollout. Carries
* the session UUID, originating cwd, model provider, git info, etc.
*/
export type CodexSessionMeta = {
id: string // UUID (ThreadId in Rust)
timestamp: string
cwd: string
originator: string // "codex"
cli_version: string
source: string // "cli" | "app_server" | etc.
model_provider?: string
agent_path?: string
agent_nickname?: string
agent_role?: string
base_instructions?: string
forked_from_id?: string
memory_mode?: string
}

/**
* A response_item payload — an OpenAI ResponseItem carrying a message,
* function call, or function call output. The `type` field inside the
* payload (distinct from the envelope's type) discriminates further.
*
* Key shapes we render:
* { type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }
* { type: "message", role: "assistant", content: [{ type: "output_text", text: "..." }] }
* { type: "function_call", name: "...", arguments: "...", call_id: "..." }
* { type: "function_call_output", call_id: "...", output: "..." }
*/
export type CodexResponseItem = {
type: string
id?: string
role?: string // "user" | "assistant" | "system"
name?: string // function name for function_call
call_id?: string // links function_call ↔ function_call_output
arguments?: string // JSON string for function_call
output?: string // result for function_call_output
content?: Array<{
type: string // "input_text" | "output_text" | "refusal" | etc.
text?: string
annotations?: unknown[]
}>
status?: string // "completed" | "in_progress" | "incomplete" | etc.
}

/**
* An event_msg payload — UI/lifecycle events emitted by codex's
* event system. The `type` inside payload discriminates:
* user_message, agent_message, tool_call, tool_output, error, etc.
*/
export type CodexEventMsg = {
type: string // "user_message" | "agent_message" | etc.
message?: string
kind?: string // "plain" | etc.
text_elements?: unknown[]
local_images?: unknown[]
}

/**
* Type guard: is this rollout line a conversation entry we should
* render in the feed? Returns true for response_item and event_msg
* which carry the actual conversation content; false for session_meta,
* compacted, and turn_context which are metadata.
*/
export function isCodexConversationEntry(line: CodexRolloutLine): boolean {
return line.type === 'response_item' || line.type === 'event_msg'
}
24 changes: 1 addition & 23 deletions src/renderer/src/features/path-picker/ui/PathPickerModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'

import { PathInput } from '@renderer/ui/PathInput'
import { relativeTime } from '@renderer/lib/relativeTime'

// Duplicate of the SessionInfo shape preload exposes. Inlined here so
// the renderer's tsconfig doesn't need to reach across into src/preload
Expand Down Expand Up @@ -391,26 +392,3 @@ function ResumeRow({
)
}

/**
* Render a timestamp as a short relative string: `3m`, `2h`, `4d`,
* `3w`. Matches the vibe of git log / GitHub timestamps. Used in the
* resume picker where exact dates would cost space and not add info.
*/
function relativeTime(ms: number): string {
const delta = Date.now() - ms
if (delta < 0) return 'now'
const s = Math.floor(delta / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
if (d < 7) return `${d}d ago`
const w = Math.floor(d / 7)
if (w < 5) return `${w}w ago`
const mo = Math.floor(d / 30)
if (mo < 12) return `${mo}mo ago`
const y = Math.floor(d / 365)
return `${y}y ago`
}
22 changes: 22 additions & 0 deletions src/renderer/src/features/system-perf/formatBytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// System-perf byte formatter.
//
// WHY SI units (1e9 / 1e6) and not binary (GiB / MiB / KiB): every
// caller in the renderer compares its readout against macOS Activity
// Monitor, which is SI. Off by ~7% from binary, but in line with what
// the user actually sees in the OS-level monitor. Don't switch to
// binary without changing every caller's surrounding label first.
//
// WHY a tunable `fractionDigits`: the badge in the header is
// space-constrained (single line, fixed width) so it prefers integer
// MB/KB readings. The popover has room for more precision on the
// GB axis. The split used to live in two separate copies of this
// function (badge had no arg, popover had a `fractionDigits = 2`
// default) — folded here with the same `2` default so badge callers
// just pass nothing.

export function formatBytes(bytes: number, fractionDigits = 2): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(fractionDigits)} GB`
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB`
return `${bytes} B`
}
12 changes: 1 addition & 11 deletions src/renderer/src/features/system-perf/ui/SystemPerfBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SystemPerformanceStats } from '@shared/performance/types'
import { formatBytes } from '@renderer/features/system-perf/formatBytes'

// Color zones for the heap badge.
//
Expand Down Expand Up @@ -66,17 +67,6 @@ function strokeFor(zone: Zone): string {
}
}

// "1.23 GB" / "456 MB". We always use SI multiples (1e9 / 1e6) to
// match Activity Monitor's display rather than Chrome's binary
// (GiB) units. Off by ~7% from GiB but in line with what users
// see in the OS-level monitor.
function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB`
return `${bytes} B`
}

type Props = {
current: SystemPerformanceStats
buffer: SystemPerformanceStats[]
Expand Down
13 changes: 1 addition & 12 deletions src/renderer/src/features/system-perf/ui/SystemPerfPopover.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from 'react'

import type { SystemPerformanceStats } from '@shared/performance/types'
import { formatBytes } from '@renderer/features/system-perf/formatBytes'

// Popover dimensions — mirror PerformancePanel's outer chrome so
// the two diagnostic surfaces look like siblings.
Expand All @@ -22,18 +23,6 @@ const CHART_HEIGHT = 140
const CHART_PAD_TOP = 8
const CHART_PAD_BOTTOM = 16

// "1.23 GB" / "456 MB" — duplicated from SystemPerfBadge so the
// popover doesn't import from a UI sibling. Keeping the helper
// local also means we can change one's formatting without
// accidentally shifting the other (the badge is space-constrained
// in the header; the popover has room for more precision).
function formatBytes(bytes: number, fractionDigits = 2): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(fractionDigits)} GB`
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB`
return `${bytes} B`
}

// Growth rate: bytes/sec over the last `seconds` window. Returns
// positive for growth, negative for shrinkage, null if there's
// not enough buffer to span the window.
Expand Down
31 changes: 31 additions & 0 deletions src/renderer/src/features/workspace/lib/sessionDisplay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Small display helpers shared by the workspace's search and
// activity modals (PromptSearchModal, AgentActivityModal).
//
// WHY this is scoped to those modal surfaces and not a generic
// "providerLabel" module: tile-tree pane headers use slightly
// different wording (full provider names, "Claude Code" vs "Claude")
// and a different glyph set, and pulling them through one shared
// formatter risks unifying surfaces that should diverge. Keep this
// strictly for list/row UIs that show a one-character provider
// marker and a path basename.

export function cwdBasename(cwd: string): string {
if (!cwd) return ''
// Trim trailing slashes so `/foo/bar/` doesn't return an empty
// basename. Then split on '/' and take the last non-empty segment.
const trimmed = cwd.replace(/\/+$/, '')
const parts = trimmed.split('/').filter(Boolean)
return parts[parts.length - 1] ?? trimmed
}

// WHY this accepts 'terminal' even though most callers only pass
// claude/codex: AgentActivityModal renders terminal sessions in the
// same list, so widening the signature means the modal doesn't have
// to special-case its row renderer. Callers that only deal with
// agent providers (PromptSearchModal) can still pass the narrower
// 'claude' | 'codex' subset — TypeScript will accept it.
export function providerGlyph(kind: 'claude' | 'codex' | 'terminal'): string {
if (kind === 'claude') return '⏺'
if (kind === 'codex') return '›'
return '$'
}
37 changes: 2 additions & 35 deletions src/renderer/src/features/workspace/ui/AgentActivityModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps'
import type { SessionId, Tab } from '@renderer/workspace/types'
import type { Workspace } from '@renderer/workspace/workspaceStore'
import type { Entry } from '@shared/types/transcript'
import { relativeTime } from '@renderer/lib/relativeTime'
import { cwdBasename, providerGlyph } from '@renderer/features/workspace/lib/sessionDisplay'

// AgentActivityModal — overview of every visible pane grouped by tab
// with a last-activity indicator per row.
Expand Down Expand Up @@ -59,29 +61,6 @@ type TabGroup = {
rows: Array<{ row: Row; idx: number }>
}

// Relative-time renderer copy-pasted from PromptSearchModal.tsx. We
// didn't extract to a shared util because the other component is the
// only other caller right now — when a third surface wants it, hoist.
function relativeTime(ts: number): string {
const diffMs = Date.now() - ts
if (diffMs < 0) return 'in the future'
const secs = Math.floor(diffMs / 1000)
if (secs < 5) return 'just now'
if (secs < 60) return `${secs}s ago`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 7) return `${days}d ago`
const weeks = Math.floor(days / 7)
if (weeks < 5) return `${weeks}w ago`
const months = Math.floor(days / 30)
if (months < 12) return `${months}mo ago`
const years = Math.floor(days / 365)
return `${years}y ago`
}

function absoluteTime(ts: number): string {
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ''
Expand All @@ -108,18 +87,6 @@ function extractLatestEntryTs(entries: Entry[]): number | null {
return null
}

function cwdBasename(cwd: string): string {
if (!cwd) return ''
const parts = cwd.split('/').filter(Boolean)
return parts[parts.length - 1] ?? cwd
}

function providerGlyph(kind: 'claude' | 'codex' | 'terminal'): string {
if (kind === 'claude') return '⏺'
if (kind === 'codex') return '›'
return '$'
}

export function AgentActivityModal({ open, workspace, onClose }: Props) {
const [selectedIdx, setSelectedIdx] = useState(0)
const [sortMode, setSortMode] = useState<SortMode>('activity')
Expand Down
Loading