Skip to content
Closed
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
26 changes: 26 additions & 0 deletions CLI/CLI+AgentSessionTitle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation

extension AgentHookContext {
/// Claude Code often never writes its session topic as a terminal title (notably on
/// `--resume`), so tabs keep the shell's cwd. Reads the topic from the transcript
/// instead and sets it as the pane's title. Sent on every call rather than only when it
/// changed: the app forgets the title when it restarts, and the hook has no way to know.
/// Best effort: any failure leaves the pane title as it was.
func reportSessionTitle(
_ hook: AgentHookProvider,
storedTranscriptPath: String?,
workspaceId: String,
surfaceId: String
) {
guard hook.provider == "claude-code",
let transcriptPath = parsed.transcriptPath ?? storedTranscriptPath,
let title = ClaudeTranscriptTitle.latestTitle(atPath: NSString(string: transcriptPath).expandingTildeInPath) else {
return
}
_ = try? client.sendV2(method: V2MethodNames.surfaceReportAgentTitle, params: [
"workspace_id": workspaceId,
"surface_id": surfaceId,
"title": title,
])
}
}
3 changes: 3 additions & 0 deletions CLI/CLI+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ extension ProgramaCLI {
setAgentPid(hook, ctx, workspaceId: workspaceId, sessionId: ctx.parsed.sessionId, pid: pid)
}
reportAgentEvent(client: ctx.client, provider: hook.provider, eventType: "session.started", workspaceId: workspaceId, surfaceId: surfaceId, sessionId: ctx.parsed.sessionId)
ctx.reportSessionTitle(hook, storedTranscriptPath: nil, workspaceId: workspaceId, surfaceId: surfaceId)
}

private func agentHookPromptSubmit(_ hook: AgentHookProvider, _ ctx: AgentHookContext) throws {
Expand Down Expand Up @@ -614,6 +615,7 @@ extension ProgramaCLI {
preferred: mapped?.surfaceId, fallback: ctx.surfaceArg, workspaceId: workspaceId, client: ctx.client
)
reportAgentStateAndEvent(client: ctx.client, provider: hook.provider, eventType: "turn.started", workspaceId: workspaceId, surfaceId: surfaceId, state: .working, sessionId: ctx.parsed.sessionId)
ctx.reportSessionTitle(hook, storedTranscriptPath: mapped?.transcriptPath, workspaceId: workspaceId, surfaceId: surfaceId)
}

private func agentHookStop(_ hook: AgentHookProvider, _ ctx: AgentHookContext) throws {
Expand Down Expand Up @@ -659,6 +661,7 @@ extension ProgramaCLI {
value: "Idle", icon: "pause.circle.fill", color: "#8E8E93"
)
reportAgentStateAndEvent(client: ctx.client, provider: hook.provider, eventType: "turn.completed", workspaceId: workspaceId, surfaceId: surfaceId, state: .idle, sessionId: ctx.parsed.sessionId)
ctx.reportSessionTitle(hook, storedTranscriptPath: mapped?.transcriptPath, workspaceId: workspaceId, surfaceId: surfaceId)
}

private func agentHookNotification(_ hook: AgentHookProvider, _ ctx: AgentHookContext) throws {
Expand Down
1 change: 1 addition & 0 deletions CLI/V2MethodNames.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ enum V2MethodNames {
static let surfaceRefresh = "surface.refresh"
static let surfaceReorder = "surface.reorder"
static let surfaceReportAgentState = "surface.report_agent_state"
static let surfaceReportAgentTitle = "surface.report_agent_title"
static let surfaceReportGitBranch = "surface.report_git_branch"
static let surfaceReportPorts = "surface.report_ports"
static let surfaceReportPr = "surface.report_pr"
Expand Down
10 changes: 10 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
AGST0001A1B2C3D4E5F60719 /* AgentActivityState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */; };
A2E500000000000000000002 /* AgentResumeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E500000000000000000001 /* AgentResumeCommand.swift */; };
A2E500000000000000000003 /* AgentResumeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E500000000000000000001 /* AgentResumeCommand.swift */; };
C7A71E0000000000000000A2 /* ClaudeTranscriptTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A71E0000000000000000A1 /* ClaudeTranscriptTitle.swift */; };
C7A71E0000000000000000A3 /* ClaudeTranscriptTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A71E0000000000000000A1 /* ClaudeTranscriptTitle.swift */; };
C7A71E0000000000000000A5 /* CLI+AgentSessionTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A71E0000000000000000A4 /* CLI+AgentSessionTitle.swift */; };
A2E500000000000000000005 /* AgentResumeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E500000000000000000004 /* AgentResumeStore.swift */; };
TTAI0001A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */; };
7501A0000000000000000003 /* GhosttyApp+ProgramStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501A0000000000000000004 /* GhosttyApp+ProgramStatus.swift */; };
Expand Down Expand Up @@ -456,6 +459,8 @@
NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarTelemetry.swift"; sourceTree = "<group>"; };
AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AgentActivityState.swift"; sourceTree = "<group>"; };
A2E500000000000000000001 /* AgentResumeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentResumeCommand.swift; sourceTree = "<group>"; };
C7A71E0000000000000000A1 /* ClaudeTranscriptTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeTranscriptTitle.swift; sourceTree = "<group>"; };
C7A71E0000000000000000A4 /* CLI+AgentSessionTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+AgentSessionTitle.swift"; sourceTree = "<group>"; };
A2E500000000000000000004 /* AgentResumeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentResumeStore.swift; sourceTree = "<group>"; };
TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalTabAgentIcon.swift"; sourceTree = "<group>"; };
7501A0000000000000000002 /* ProgramStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramStatus.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -936,6 +941,7 @@
NRSP0073A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift */,
AGST0002A1B2C3D4E5F60719 /* AgentActivityState.swift */,
A2E500000000000000000001 /* AgentResumeCommand.swift */,
C7A71E0000000000000000A1 /* ClaudeTranscriptTitle.swift */,
A2E500000000000000000004 /* AgentResumeStore.swift */,
TTAI0002A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift */,
7501A0000000000000000002 /* ProgramStatus.swift */,
Expand Down Expand Up @@ -1135,6 +1141,7 @@
7B3F9A10C2D4E5F6A7B80009 /* CLI+Ports.swift */,
A1D000000000000000000006 /* CLI+Handoff.swift */,
A2E500000000000000000006 /* CLI+AgentResume.swift */,
C7A71E0000000000000000A4 /* CLI+AgentSessionTitle.swift */,
F1D600000000000000000001 /* CLI+Usage.swift */,
B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */,
B900003CA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift */,
Expand Down Expand Up @@ -1502,6 +1509,7 @@
NRSP0074A1B2C3D4E5F60719 /* Workspace+SidebarTelemetry.swift in Sources */,
AGST0001A1B2C3D4E5F60719 /* AgentActivityState.swift in Sources */,
A2E500000000000000000002 /* AgentResumeCommand.swift in Sources */,
C7A71E0000000000000000A2 /* ClaudeTranscriptTitle.swift in Sources */,
A2E500000000000000000005 /* AgentResumeStore.swift in Sources */,
TTAI0001A1B2C3D4E5F60719 /* TerminalTabAgentIcon.swift in Sources */,
7501A0000000000000000001 /* ProgramStatus.swift in Sources */,
Expand Down Expand Up @@ -1809,6 +1817,8 @@
A2E500000000000000000007 /* CLI+AgentResume.swift in Sources */,
F1D600000000000000000002 /* CLI+Usage.swift in Sources */,
A2E500000000000000000003 /* AgentResumeCommand.swift in Sources */,
C7A71E0000000000000000A3 /* ClaudeTranscriptTitle.swift in Sources */,
C7A71E0000000000000000A5 /* CLI+AgentSessionTitle.swift in Sources */,
B900003BA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift in Sources */,
B900003DA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift in Sources */,
E9C500000000000000000001 /* CLI+WorktreeIssue.swift in Sources */,
Expand Down
49 changes: 49 additions & 0 deletions Sources/ClaudeTranscriptTitle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation

/// Reads the topic of a Claude Code session out of its transcript JSONL. Claude appends
/// `{"type":"custom-title","customTitle":...}` (a `/rename`) and
/// `{"type":"ai-title","aiTitle":...}` lines as the session goes on, so the latest of each
/// sits near the end of the file. Compiled into both the app and the CLI.
enum ClaudeTranscriptTitle {
static let defaultTailBytes = 512 * 1024

private static let customMarker = Data(#""type":"custom-title""#.utf8)
private static let aiMarker = Data(#""type":"ai-title""#.utf8)

/// The latest `/rename` title, else the latest AI title, from the last `tailBytes` of the
/// file. Nil when the file is unreadable or holds neither.
static func latestTitle(atPath path: String, tailBytes: Int = defaultTailBytes) -> String? {
guard tailBytes > 0, let handle = FileHandle(forReadingAtPath: path) else { return nil }
defer { try? handle.close() }
guard let size = try? handle.seekToEnd() else { return nil }
let start = size > UInt64(tailBytes) ? size - UInt64(tailBytes) : 0
guard (try? handle.seek(toOffset: start)) != nil,
let data = try? handle.readToEnd() else { return nil }
return latestTitle(in: data, dropsLeadingPartialLine: start > 0)
}

/// `dropsLeadingPartialLine` is true when `data` starts mid-file, so its first line may be cut.
static func latestTitle(in data: Data, dropsLeadingPartialLine: Bool = false) -> String? {
var lines = data.split(separator: UInt8(ascii: "\n"), omittingEmptySubsequences: true)
if dropsLeadingPartialLine, !lines.isEmpty { lines.removeFirst() }

var aiTitle: String?
for line in lines.reversed() {
if line.range(of: customMarker) != nil,
let title = value(of: "customTitle", in: line) {
return title
}
if aiTitle == nil, line.range(of: aiMarker) != nil {
aiTitle = value(of: "aiTitle", in: line)
}
}
return aiTitle
}

private static func value(of key: String, in line: Data) -> String? {
guard let object = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any],
let raw = object[key] as? String else { return nil }
let title = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return title.isEmpty ? nil : title
}
}
9 changes: 9 additions & 0 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2516,6 +2516,15 @@ class TabManager: ObservableObject {
}
}

func applyAgentTitle(tabId: UUID, panelId: UUID, title: String) {
guard let tab = workspace(withId: tabId),
tab.panels[panelId] != nil,
tab.applyAgentTitle(panelId: panelId, title: title) else { return }
if selectedTabId == tabId && tab.focusedPanelId == panelId {
updateWindowTitle(for: tab)
}
}

private func updateWindowTitleForSelectedTab() {
guard let selectedTabId,
let tab = workspace(withId: selectedTabId) else {
Expand Down
35 changes: 35 additions & 0 deletions Sources/TerminalController+Telemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,41 @@ extension TerminalController {
])
}

/// Sets a pane's live title from an agent session's topic. Telemetry-shaped (off-main parse,
/// main.async mutate, no focus or selection change); the title goes through the same path as
/// an OSC title, so a manual rename still wins.
nonisolated func v2SurfaceReportAgentTitle(params: [String: Any]) -> V2CallResult {
guard let workspaceId = v2CachedUUID(params, "workspace_id") else {
return v2InvalidParam("workspace_id")
}
guard let surfaceId = v2CachedUUID(params, "surface_id") else {
return v2InvalidParam("surface_id")
}
guard let title = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty else {
return .err(code: "invalid_params", message: "Missing title", data: nil)
}
guard SidebarTelemetryLimits.utf8ByteCount(title) <= SidebarTelemetryLimits.maxAgentTitleBytes else {
return .err(
code: "invalid_params",
message: "title exceeds the UTF-8 limit of \(SidebarTelemetryLimits.maxAgentTitleBytes) bytes",
data: nil
)
}

v2ScheduleSurfaceTelemetryMutation(workspaceId: workspaceId, surfaceId: surfaceId) { tabManager, _, sid in
tabManager.applyAgentTitle(tabId: workspaceId, panelId: sid, title: title)
}

return .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"title": title,
])
}

nonisolated func v2SurfaceReportShellState(params: [String: Any]) -> V2CallResult {
guard let workspaceId = v2CachedUUID(params, "workspace_id") else {
return v2InvalidParam("workspace_id")
Expand Down
2 changes: 2 additions & 0 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1915,6 +1915,8 @@ class TerminalController {
return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params))
case "surface.report_pwd":
return v2Result(id: id, self.v2SurfaceReportPwd(params: params))
case "surface.report_agent_title":
return v2Result(id: id, self.v2SurfaceReportAgentTitle(params: params))
case "surface.report_shell_state":
return v2Result(id: id, self.v2SurfaceReportShellState(params: params))
case "surface.report_git_branch":
Expand Down
1 change: 1 addition & 0 deletions Sources/V2CommandCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ enum V2CommandCatalog {
"surface.refresh",
"surface.reorder",
"surface.report_agent_state",
"surface.report_agent_title",
"surface.report_git_branch",
"surface.report_ports",
"surface.report_pr",
Expand Down
13 changes: 13 additions & 0 deletions Sources/Workspace+SidebarTelemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import CoreText
enum SidebarTelemetryLimits {
static let maxKeyBytes = 512
static let maxDirectoryBytes = 4 * 1024
static let maxAgentTitleBytes = 1024
static let maxGitBranchBytes = 1024
static let maxPullRequestURLBytes = 4 * 1024
static let maxPullRequestLabelBytes = 64
Expand Down Expand Up @@ -526,6 +527,18 @@ extension Workspace {
return didMutate
}

/// Sets the panel's live title from an agent session's topic and holds it against terminal
/// startup titles, like a restored title: the shell's cwd or resume command line and the
/// agent's own "Claude Code" banner no longer replace it, while any other title still does.
@discardableResult
func applyAgentTitle(panelId: UUID, title: String) -> Bool {
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
panelsHoldingRestoredTitle.remove(panelId)
let didMutate = updatePanelTitle(panelId: panelId, title: title)
panelsHoldingRestoredTitle.insert(panelId)
return didMutate
}

/// Titles a terminal shows before it has anything of its own to say: a shell's cwd
/// (`/path`, `~/path`, `user@host: ~/path`), the `AgentResumeCommand` line being run, and
/// an agent's banner before its session has a topic.
Expand Down
7 changes: 4 additions & 3 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ final class Workspace: Identifiable, ObservableObject {
/// session restore), as opposed to the displayTitle placeholder seeded at
/// panel creation. Only these titles may be promoted to the workspace title.
var panelsWithLiveTitle: Set<UUID> = []
/// Panels whose title came from session restore and has not yet been replaced by a real
/// title. While held, the restored shell's startup titles (its cwd, a typed resume
/// command, an agent's untitled banner) are ignored so the saved title stays visible.
/// Panels whose title came from session restore or from an agent session's topic
/// (`applyAgentTitle`) and has not yet been replaced by a real terminal title. While held,
/// the shell's startup titles (its cwd, a typed resume command, an agent's untitled
/// banner) are ignored so that title stays visible.
var panelsHoldingRestoredTitle: Set<UUID> = []
@Published var panelCustomTitles: [UUID: String] = [:]
@Published var pinnedPanelIds: Set<UUID> = []
Expand Down
36 changes: 36 additions & 0 deletions contracts/v2/methods.json
Original file line number Diff line number Diff line change
Expand Up @@ -3393,6 +3393,42 @@
},
"threading": "off_main"
},
"surface.report_agent_title": {
"auth_required": true,
"debug_only": false,
"description": "Terminal surface (pane content) lifecycle and IO. (report agent title). Sets the pane's live title from an agent session's topic; a manual rename still wins and a later terminal startup title (cwd, resume command, agent banner) does not replace it.",
"errors": [
"invalid_params"
],
"focus_intent": false,
"handler": "v2SurfaceReportAgentTitle",
"params": {
"additionalProperties": false,
"properties": {
"title": {
"type": "string"
},
"surface_id": {
"format": "surface:N",
"type": "string"
},
"workspace_id": {
"format": "workspace:N",
"type": "string"
}
},
"required": [
"surface_id",
"title",
"workspace_id"
],
"type": "object"
},
"result": {
"type": "object"
},
"threading": "off_main"
},
"surface.report_git_branch": {
"auth_required": true,
"debug_only": false,
Expand Down
1 change: 1 addition & 0 deletions docs/socket-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ command threading policy" in the root `CLAUDE.md`):
- report_tty -> `surface.report_tty`
- ports_kick -> `surface.ports_kick`
- report_pwd -> `surface.report_pwd`
- (no v1 equivalent) -> `surface.report_agent_title` (`workspace_id`, `surface_id`, `title`: sets the pane's live title from an agent session's topic; a manual rename still wins, and a later shell startup title such as the cwd, a resume command or the "Claude Code" banner does not replace it)
- report_shell_state -> `surface.report_shell_state` (shares the `SocketFastPathState` dedup helper)
- report_git_branch -> `surface.report_git_branch`
- clear_git_branch -> `surface.clear_git_branch`
Expand Down
Loading
Loading