mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(tabs): drop the SSH transcript pull and slim the rename plumbing
The remote branch read the whole transcript through the SSH filesystem provider on every non-decorative title frame — the contract has no ranged read (`IFilesystemProvider.readFile` takes a path only), so a tab label cost up to 8 MB over the link, per pane, on the mux that also carries PTY data. `fs.search` is not a substitute: `--max-count 100` returns the oldest matches, not the newest, so a long session would resolve a stale rename. Skip SSH-hosted panes in the renderer instead. That also stops main from reading a remote path against the local filesystem, where it could resolve to an unrelated session file. Covering SSH needs a bounded remote tail read first. Also folds the plumbing down: one `updateUnifiedTerminalLabelField` replaces three near-identical unified-label updaters, the single-caller `readClaudeSessionRenamedTitle` wrapper and the duplicated marker filter are gone, and `stripLeadingAgentTitleDecoration` replaces its open-coded equivalent.
This commit is contained in:
@@ -59,4 +59,8 @@ describe('readAgentSessionRenamedTitle', () => {
|
||||
it('ignores an empty transcript path', async () => {
|
||||
await expect(readAgentSessionRenamedTitle({ transcriptPath: ' ' })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('resolves null for an ill-typed transcript path instead of rejecting', async () => {
|
||||
await expect(readAgentSessionRenamedTitle({ transcriptPath: 7 as never })).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,46 +4,32 @@ import { createInterface } from 'node:readline'
|
||||
import {
|
||||
CLAUDE_CUSTOM_TITLE_RECORD_MARKER,
|
||||
findClaudeSessionRenameRecord,
|
||||
readClaudeSessionRenamedTitle,
|
||||
type ClaudeSessionRenameRecord
|
||||
} from '../shared/agent-session-rename-title'
|
||||
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
|
||||
|
||||
// Why: Claude re-appends the rename record on most turns, so measured against
|
||||
// real transcripts (2.8 MB / 37 records, newest 403 B from EOF) a short tail
|
||||
// answers nearly every scan. A rename that is never re-appended still exists
|
||||
// only once, near the top, so an empty tail widens to a full scan.
|
||||
// answers nearly every scan. A rename that is never re-appended exists only
|
||||
// once, near the top, so an empty tail widens.
|
||||
const TRANSCRIPT_TAIL_SCAN_BYTES = 256 * 1024
|
||||
// Why: a rename record is one short line anywhere in the transcript, so the scan
|
||||
// streams the file rather than buffering it. Very long sessions are tail-scanned
|
||||
// from this offset; a rename that old is already superseded by the tab's
|
||||
// persisted value.
|
||||
// Why: the widened scan streams rather than buffers, but cap how far back it
|
||||
// starts — a rename that old is already superseded by the tab's persisted value.
|
||||
const MAX_TRANSCRIPT_SCAN_BYTES = 64 * 1024 * 1024
|
||||
const MAX_REMOTE_TRANSCRIPT_READ_BYTES = 8 * 1024 * 1024
|
||||
|
||||
export type AgentSessionRenamedTitleArgs = {
|
||||
transcriptPath: string
|
||||
/** SSH connection owning the transcript; omitted for local/WSL panes. */
|
||||
connectionId?: string
|
||||
}
|
||||
/** Local transcripts only — the renderer skips panes hosted over SSH. */
|
||||
export type AgentSessionRenamedTitleArgs = { transcriptPath: string }
|
||||
|
||||
/**
|
||||
* The deliberate rename (Claude `/rename`) recorded in one agent transcript, or
|
||||
* null when the session has none. Best-effort: any read failure resolves null so
|
||||
* the tab keeps its generated title instead of the call rejecting.
|
||||
* null when the session has none. Best-effort: any read failure — including an
|
||||
* ill-typed IPC payload — resolves null so the tab keeps its generated title.
|
||||
*/
|
||||
export async function readAgentSessionRenamedTitle(
|
||||
args: AgentSessionRenamedTitleArgs
|
||||
): Promise<string | null> {
|
||||
const transcriptPath = args.transcriptPath.trim()
|
||||
if (!transcriptPath) {
|
||||
return null
|
||||
}
|
||||
const connectionId = args.connectionId?.trim()
|
||||
try {
|
||||
return connectionId
|
||||
? await readRemoteRenamedTitle(transcriptPath, connectionId)
|
||||
: await readLocalRenamedTitle(transcriptPath)
|
||||
const transcriptPath = args.transcriptPath.trim()
|
||||
return transcriptPath ? await readLocalRenamedTitle(transcriptPath) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -69,42 +55,16 @@ async function scanTranscriptFrom(
|
||||
): Promise<ClaudeSessionRenameRecord | null> {
|
||||
const stream = createReadStream(transcriptPath, { encoding: 'utf8', start })
|
||||
try {
|
||||
const lines = createInterface({ input: stream, crlfDelay: Infinity })
|
||||
return findClaudeSessionRenameRecord(await collectLines(lines))
|
||||
// Why: transcripts are mostly long message records — keep only the lines
|
||||
// that can carry a rename instead of buffering the file.
|
||||
const candidates: string[] = []
|
||||
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
|
||||
if (line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) {
|
||||
candidates.push(line)
|
||||
}
|
||||
}
|
||||
return findClaudeSessionRenameRecord(candidates)
|
||||
} finally {
|
||||
stream.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async function collectLines(lines: AsyncIterable<string>): Promise<string[]> {
|
||||
const collected: string[] = []
|
||||
for await (const line of lines) {
|
||||
if (line.includes(CLAUDE_CUSTOM_TITLE_RECORD_MARKER)) {
|
||||
collected.push(line)
|
||||
}
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
async function readRemoteRenamedTitle(
|
||||
transcriptPath: string,
|
||||
connectionId: string
|
||||
): Promise<string | null> {
|
||||
// Why: SSH-hosted agents write the transcript on the remote host, so the local
|
||||
// filesystem has nothing to scan. No provider (disconnected) degrades to null.
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
// Why: the remote read has no tail seek, so cap it well below the local budget
|
||||
// rather than pulling a long session over the link for one short record.
|
||||
const { size } = await provider.stat(transcriptPath)
|
||||
if (size > MAX_REMOTE_TRANSCRIPT_READ_BYTES) {
|
||||
return null
|
||||
}
|
||||
const { content, isBinary } = await provider.readFile(transcriptPath)
|
||||
if (isBinary || !content) {
|
||||
return null
|
||||
}
|
||||
return readClaudeSessionRenamedTitle(content.split('\n'))
|
||||
}
|
||||
|
||||
@@ -14,14 +14,7 @@ export function registerAgentSessionRenameHandlers(): void {
|
||||
ipcMain.removeHandler('agentSession:getRenamedTitle')
|
||||
ipcMain.handle(
|
||||
'agentSession:getRenamedTitle',
|
||||
async (_event, args?: AgentSessionRenamedTitleArgs): Promise<string | null> => {
|
||||
if (!args || typeof args.transcriptPath !== 'string') {
|
||||
return null
|
||||
}
|
||||
return readAgentSessionRenamedTitle({
|
||||
transcriptPath: args.transcriptPath,
|
||||
...(typeof args.connectionId === 'string' ? { connectionId: args.connectionId } : {})
|
||||
})
|
||||
}
|
||||
async (_event, args?: AgentSessionRenamedTitleArgs): Promise<string | null> =>
|
||||
typeof args?.transcriptPath === 'string' ? readAgentSessionRenamedTitle(args) : null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2468,11 +2468,8 @@ export type PreloadApi = {
|
||||
}) => Promise<void>
|
||||
}
|
||||
agentSession: {
|
||||
/** Deliberate in-agent rename (Claude `/rename`) recorded in a transcript. */
|
||||
getRenamedTitle: (args: {
|
||||
transcriptPath: string
|
||||
connectionId?: string
|
||||
}) => Promise<string | null>
|
||||
/** Deliberate in-agent rename (Claude `/rename`) recorded in a local transcript. */
|
||||
getRenamedTitle: (args: { transcriptPath: string }) => Promise<string | null>
|
||||
}
|
||||
preflight: PreflightApi
|
||||
notifications: {
|
||||
|
||||
@@ -2140,10 +2140,8 @@ const api = {
|
||||
},
|
||||
|
||||
agentSession: {
|
||||
getRenamedTitle: (args: {
|
||||
transcriptPath: string
|
||||
connectionId?: string
|
||||
}): Promise<string | null> => ipcRenderer.invoke('agentSession:getRenamedTitle', args)
|
||||
getRenamedTitle: (args: { transcriptPath: string }): Promise<string | null> =>
|
||||
ipcRenderer.invoke('agentSession:getRenamedTitle', args)
|
||||
},
|
||||
|
||||
preflight: {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { isAgentRenamedTerminalTitle } from '../../../shared/agent-session-rename-title'
|
||||
|
||||
// Why: a live OSC title change is the only immediate signal that a deliberate
|
||||
// `/rename` may have happened — the agent fires no hook for it. One refresh per
|
||||
// tab is in flight at a time so a burst of title frames cannot fan out into
|
||||
// concurrent transcript scans; the newest frame of the burst is queued rather
|
||||
// than dropped, or the tab would keep a rename verdict for a title it no longer
|
||||
// shows until some later title change happens to arrive.
|
||||
const inFlightTabIds = new Set<string>()
|
||||
const queuedByTabId = new Map<string, AgentRenamedTabTitleRefreshArgs>()
|
||||
// `/rename` may have happened — the agent fires no hook for it. Keyed entry
|
||||
// means a scan is open for that tab; its value is the newest frame waiting
|
||||
// behind it. Queuing rather than dropping matters because Claude stops
|
||||
// auto-titling after a rename, so a dropped frame has no later trigger.
|
||||
const openRefreshByTabId = new Map<string, AgentRenamedTabTitleRefreshArgs | null>()
|
||||
|
||||
export type AgentRenamedTabTitleRefreshArgs = {
|
||||
tabId: string
|
||||
@@ -15,7 +13,6 @@ export type AgentRenamedTabTitleRefreshArgs = {
|
||||
liveTitle: string
|
||||
/** Transcript per pane of the tab — a split tab can run several sessions. */
|
||||
transcriptPaths: readonly string[]
|
||||
connectionId: string | null
|
||||
apply: (agentRenamedTitle: string | null) => void
|
||||
}
|
||||
|
||||
@@ -27,18 +24,15 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe
|
||||
if (!getRenamedTitle || args.transcriptPaths.length === 0) {
|
||||
return
|
||||
}
|
||||
if (inFlightTabIds.has(args.tabId)) {
|
||||
queuedByTabId.set(args.tabId, args)
|
||||
if (openRefreshByTabId.has(args.tabId)) {
|
||||
openRefreshByTabId.set(args.tabId, args)
|
||||
return
|
||||
}
|
||||
inFlightTabIds.add(args.tabId)
|
||||
openRefreshByTabId.set(args.tabId, null)
|
||||
void (async () => {
|
||||
try {
|
||||
for (const transcriptPath of args.transcriptPaths) {
|
||||
const renamedTitle = await getRenamedTitle({
|
||||
transcriptPath,
|
||||
...(args.connectionId ? { connectionId: args.connectionId } : {})
|
||||
})
|
||||
const renamedTitle = await getRenamedTitle({ transcriptPath })
|
||||
if (isAgentRenamedTerminalTitle(args.liveTitle, renamedTitle)) {
|
||||
args.apply(renamedTitle)
|
||||
return
|
||||
@@ -48,10 +42,9 @@ export function scheduleAgentRenamedTabTitleRefresh(args: AgentRenamedTabTitleRe
|
||||
} catch {
|
||||
// Best-effort: an unreadable transcript leaves the generated title in place.
|
||||
} finally {
|
||||
inFlightTabIds.delete(args.tabId)
|
||||
const queued = queuedByTabId.get(args.tabId)
|
||||
const queued = openRefreshByTabId.get(args.tabId)
|
||||
openRefreshByTabId.delete(args.tabId)
|
||||
if (queued) {
|
||||
queuedByTabId.delete(args.tabId)
|
||||
scheduleAgentRenamedTabTitleRefresh(queued)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveTerminalTabTitle,
|
||||
resolveUnifiedTabLabel
|
||||
} from '../../../../shared/tab-title-resolution'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers'
|
||||
|
||||
const WORKTREE_ID = 'repo1::/path/wt1'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
@@ -130,6 +130,17 @@ describe('deliberate in-agent rename vs generated tab title', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not scan an SSH-hosted pane, whose transcript lives on the remote host', async () => {
|
||||
const store = createTestStore()
|
||||
const tabId = seedAgentTab(store)
|
||||
store.setState({ repos: [{ ...TEST_REPO, connectionId: 'ssh-1' }] })
|
||||
|
||||
store.getState().updateTabTitle(tabId, '✳ billing-fix')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(getRenamedTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not scan transcripts while generated titles are off', async () => {
|
||||
const store = createTestStore()
|
||||
const tabId = seedAgentTab(store)
|
||||
|
||||
@@ -333,52 +333,19 @@ function getTerminalTabOwnerWorktreeId(
|
||||
return terminalTabOwnerCache.get(tabId) ?? null
|
||||
}
|
||||
|
||||
function updateUnifiedTerminalLabel(
|
||||
unifiedTabs: Tab[],
|
||||
terminalTabId: string,
|
||||
label: string
|
||||
): Tab[] | null {
|
||||
/** Null when the terminal has no unified tab or the field already holds `value`. */
|
||||
function updateUnifiedTerminalLabelField<
|
||||
K extends 'label' | 'generatedLabel' | 'agentRenamedLabel'
|
||||
>(unifiedTabs: Tab[], terminalTabId: string, field: K, value: Tab[K]): Tab[] | null {
|
||||
const unifiedIndex = unifiedTabs.findIndex(
|
||||
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
|
||||
)
|
||||
if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.label === label) {
|
||||
return null
|
||||
}
|
||||
return unifiedTabs.map((entry, index) => (index === unifiedIndex ? { ...entry, label } : entry))
|
||||
}
|
||||
|
||||
function updateUnifiedTerminalAgentRenamedLabel(
|
||||
unifiedTabs: Tab[],
|
||||
terminalTabId: string,
|
||||
agentRenamedLabel: string | null
|
||||
): Tab[] | null {
|
||||
const unifiedIndex = unifiedTabs.findIndex(
|
||||
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
|
||||
)
|
||||
if (
|
||||
unifiedIndex === -1 ||
|
||||
(unifiedTabs[unifiedIndex]?.agentRenamedLabel ?? null) === agentRenamedLabel
|
||||
) {
|
||||
const current = unifiedTabs[unifiedIndex]
|
||||
if (!current || (current[field] ?? null) === (value ?? null)) {
|
||||
return null
|
||||
}
|
||||
return unifiedTabs.map((entry, index) =>
|
||||
index === unifiedIndex ? { ...entry, agentRenamedLabel } : entry
|
||||
)
|
||||
}
|
||||
|
||||
function updateUnifiedTerminalGeneratedLabel(
|
||||
unifiedTabs: Tab[],
|
||||
terminalTabId: string,
|
||||
generatedLabel: string
|
||||
): Tab[] | null {
|
||||
const unifiedIndex = unifiedTabs.findIndex(
|
||||
(entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId
|
||||
)
|
||||
if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.generatedLabel === generatedLabel) {
|
||||
return null
|
||||
}
|
||||
return unifiedTabs.map((entry, index) =>
|
||||
index === unifiedIndex ? { ...entry, generatedLabel } : entry
|
||||
index === unifiedIndex ? { ...entry, [field]: value } : entry
|
||||
)
|
||||
}
|
||||
|
||||
@@ -501,6 +468,13 @@ function requestAgentRenamedTabTitle(get: () => AppState, tabId: string, liveTit
|
||||
if (!ownerWorktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: an SSH-hosted agent writes its transcript on the remote host, and the
|
||||
// remote filesystem contract has no ranged read — scanning it would pull the
|
||||
// whole transcript over the link on every title frame. Skip instead, and never
|
||||
// read the remote path locally where it could resolve to an unrelated file.
|
||||
if (getRemoteConnectionIdForWorktree(state, ownerWorktreeId)) {
|
||||
return
|
||||
}
|
||||
const transcriptPaths = Object.entries(state.agentStatusByPaneKey).flatMap(([paneKey, entry]) => {
|
||||
const transcriptPath = entry?.providerSession?.transcriptPath?.trim()
|
||||
return transcriptPath && getTabIdFromPaneKey(paneKey) === tabId ? [transcriptPath] : []
|
||||
@@ -509,7 +483,6 @@ function requestAgentRenamedTabTitle(get: () => AppState, tabId: string, liveTit
|
||||
tabId,
|
||||
liveTitle,
|
||||
transcriptPaths,
|
||||
connectionId: getRemoteConnectionIdForWorktree(state, ownerWorktreeId),
|
||||
apply: (agentRenamedTitle) => get().setTabAgentRenamedTitle(tabId, agentRenamedTitle)
|
||||
})
|
||||
}
|
||||
@@ -2042,9 +2015,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
if (!currentTab || (currentTab.agentRenamedTitle ?? null) === agentRenamedTitle) {
|
||||
return s
|
||||
}
|
||||
const unifiedTabsWithRenamedLabel = updateUnifiedTerminalAgentRenamedLabel(
|
||||
const unifiedTabsWithRenamedLabel = updateUnifiedTerminalLabelField(
|
||||
s.unifiedTabsByWorktree[ownerWorktreeId] ?? [],
|
||||
tabId,
|
||||
'agentRenamedLabel',
|
||||
agentRenamedTitle
|
||||
)
|
||||
scheduleRuntimeGraphSync()
|
||||
@@ -2084,9 +2058,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
const nextTitle = title.trim() || getFallbackTabTitle(currentTab)
|
||||
const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? []
|
||||
if (isDecorativeAgentTitleFrameChange(currentTab.title, nextTitle)) {
|
||||
const unifiedTabsWithCurrentLabel = updateUnifiedTerminalLabel(
|
||||
const unifiedTabsWithCurrentLabel = updateUnifiedTerminalLabelField(
|
||||
currentUnifiedTabs,
|
||||
tabId,
|
||||
'label',
|
||||
currentTab.title
|
||||
)
|
||||
return unifiedTabsWithCurrentLabel
|
||||
@@ -2098,9 +2073,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
}
|
||||
: s
|
||||
}
|
||||
const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabel(
|
||||
const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabelField(
|
||||
currentUnifiedTabs,
|
||||
tabId,
|
||||
'label',
|
||||
nextTitle
|
||||
)
|
||||
if (currentTab.title === nextTitle) {
|
||||
@@ -2200,9 +2176,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
tab.id === tabId ? { ...tab, generatedTitle } : tab
|
||||
)
|
||||
const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? []
|
||||
const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalGeneratedLabel(
|
||||
const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalLabelField(
|
||||
currentUnifiedTabs,
|
||||
tabId,
|
||||
'generatedLabel',
|
||||
generatedTitle
|
||||
)
|
||||
scheduleRuntimeGraphSync()
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
import type { AgentType } from './agent-status-types'
|
||||
import { isAgentRenamedTerminalTitle } from './agent-session-rename-title'
|
||||
import { isClaudeManagementTitle } from './agent-title-core'
|
||||
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
|
||||
import {
|
||||
stripLeadingAgentTitleDecoration,
|
||||
stripLeadingAgentTitleDecorationOrEmpty
|
||||
} from './agent-title-decoration'
|
||||
import { formatAgentTypeLabel } from './agent-type-label'
|
||||
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
|
||||
import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
|
||||
@@ -132,7 +135,7 @@ export function getAgentRowConversationName(
|
||||
}
|
||||
const liveTitle = tab.title?.trim() ?? ''
|
||||
if (isAgentRenamedTerminalTitle(liveTitle, tab.agentRenamedTitle)) {
|
||||
return stripLeadingAgentTitleDecorationOrEmpty(liveTitle) || liveTitle
|
||||
return stripLeadingAgentTitleDecoration(liveTitle)
|
||||
}
|
||||
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
|
||||
return liveTitle
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findClaudeSessionRenameRecord,
|
||||
isAgentRenamedTerminalTitle,
|
||||
readClaudeSessionRenamedTitle
|
||||
isAgentRenamedTerminalTitle
|
||||
} from './agent-session-rename-title'
|
||||
|
||||
const readClaudeSessionRenamedTitle = (lines: Iterable<string>): string | null =>
|
||||
findClaudeSessionRenameRecord(lines)?.customTitle ?? null
|
||||
|
||||
// Records copied from a real Claude transcript for a session that answered one
|
||||
// prompt and was then renamed with `/rename billing-fix`.
|
||||
const AI_TITLE_LINE = JSON.stringify({
|
||||
@@ -18,7 +20,7 @@ const CUSTOM_TITLE_LINE = JSON.stringify({
|
||||
sessionId: 'ac1a3f53-82b1-41ab-8db8-028c1fc15c48'
|
||||
})
|
||||
|
||||
describe('readClaudeSessionRenamedTitle', () => {
|
||||
describe('the rename a Claude transcript currently records', () => {
|
||||
it('reads the deliberate rename and ignores auto-generated titles', () => {
|
||||
expect(readClaudeSessionRenamedTitle([AI_TITLE_LINE, CUSTOM_TITLE_LINE])).toBe('billing-fix')
|
||||
})
|
||||
|
||||
@@ -9,9 +9,8 @@ import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoratio
|
||||
/** Cheap prefilter for transcript readers before they buffer or parse a line. */
|
||||
export const CLAUDE_CUSTOM_TITLE_RECORD_MARKER = '"custom-title"'
|
||||
|
||||
/** The newest rename record in a range of transcript lines. `customTitle` is
|
||||
* null when the agent cleared the name — still a record, so callers must not
|
||||
* keep searching earlier ranges for an older one. */
|
||||
/** `customTitle` is null when the agent cleared the name — still a record, so
|
||||
* callers must not keep searching earlier ranges for an older one. */
|
||||
export type ClaudeSessionRenameRecord = { customTitle: string | null }
|
||||
|
||||
/**
|
||||
@@ -36,27 +35,14 @@ export function findClaudeSessionRenameRecord(
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
continue
|
||||
const { type, customTitle } = (parsed ?? {}) as { type?: unknown; customTitle?: unknown }
|
||||
if (type === 'custom-title') {
|
||||
record = { customTitle: typeof customTitle === 'string' ? customTitle.trim() || null : null }
|
||||
}
|
||||
const { type, customTitle } = parsed as { type?: unknown; customTitle?: unknown }
|
||||
if (type !== 'custom-title') {
|
||||
continue
|
||||
}
|
||||
record = { customTitle: typeof customTitle === 'string' ? customTitle.trim() || null : null }
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's current deliberate rename from Claude transcript lines, or null
|
||||
* when none survives. Later records win; an empty `customTitle` is Claude
|
||||
* clearing the name, which must fall back to Orca's generated title.
|
||||
*/
|
||||
export function readClaudeSessionRenamedTitle(lines: Iterable<string>): string | null {
|
||||
return findClaudeSessionRenameRecord(lines)?.customTitle ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the pane's live OSC title still carries the recorded deliberate
|
||||
* rename. Matching (rather than substituting the recorded name) keeps the label
|
||||
|
||||
Reference in New Issue
Block a user