Show coding agent icons on mobile terminal tabs (#6792)

* Show coding agent icons on mobile terminal tabs

Move agent title decoration and terminal title parsing utilities
from the desktop renderer to shared code for reuse on mobile.

* Extract agent title stripping and terminal agent resolution to shared
* Implement mobile agent identity resolution and title-cleaning helpers
* Render agent icons on mobile terminal tabs when an agent is active
* Strip leading status glyphs from tab titles when showing an icon

* Suppress PTY resize on resume for mobile-driven terminals

Avoid reasserting the PTY size on resume if desktop resizing is
suppressed. This prevents overriding the intentional drift from
desktop dimensions for parked or mobile-driven terminals.
This commit is contained in:
Jinjing 2026-06-29 16:06:27 -07:00 committed by GitHub
parent 53e2582cbe
commit 7caa582841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 212 additions and 36 deletions

View File

@ -118,7 +118,7 @@ import {
shouldRecoverTerminalOnAppStateChange
} from '../../../../src/terminal/terminal-foreground-recovery'
import { MobileBrowserPane } from '../../../../src/browser/MobileBrowserPane'
import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url'
import { normalizeBrowserUrl } from '../../../../src/browser/browser-url'
import { StatusDot } from '../../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
@ -153,6 +153,10 @@ import {
mobileSessionTabsEqual,
terminalRecordsEqual
} from '../../../../src/session/mobile-terminal-records'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from '../../../../src/session/mobile-terminal-tab-agent'
import {
buildMobileNewTabAgentOptions,
type MobileNewTabAgentOption,
@ -290,26 +294,6 @@ function getActiveTabIdForHandle(
)
}
function getMobileSessionTabTitle(tab: MobileSessionTab): string {
if (tab.type === 'browser') {
const title = tab.title.trim()
if (title && !isBlankBrowserUrl(title)) {
return title
}
if (isBlankBrowserUrl(tab.url)) {
return 'New Browser'
}
return 'Browser'
}
if (tab.type === 'markdown') {
return tab.title || 'Markdown'
}
if (tab.type === 'file') {
return tab.title || 'File'
}
return tab.title || 'Terminal'
}
function MarkdownReader({
documentId,
doc,
@ -4693,6 +4677,11 @@ export default function SessionScreen() {
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
return agentId ? <MobileAgentIcon agentId={agentId} size={13} /> : null
})()}
<Text
style={[
styles.tabText,

View File

@ -6,7 +6,7 @@ import type {
MobileSyntaxSegment
} from '../../../../src/session/mobile-file-syntax'
import type { TerminalRecord } from '../../../../src/session/mobile-terminal-records'
import type { DiffComment } from '../../../../../src/shared/types'
import type { DiffComment, TuiAgent } from '../../../../../src/shared/types'
import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
export type Terminal = TerminalRecord
@ -23,6 +23,7 @@ export type MobileSessionTab =
status?: 'pending-handle' | 'ready'
terminal: string | null
agentStatus?: AgentStatusEntry | null
launchAgent?: TuiAgent
terminalTheme?: MobileTerminalTheme
isActive: boolean
}

View File

@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { TuiAgent } from '../../../src/shared/types'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from './mobile-terminal-tab-agent'
function agentStatus(agentType: string | undefined): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
paneKey: 'tab-1:leaf-1',
...(agentType ? { agentType } : {}),
stateHistory: []
}
}
function terminalTab(
title: string,
options: { agentType?: string; id?: string; launchAgent?: TuiAgent } = {}
): Extract<MobileSessionTab, { type: 'terminal' }> {
return {
type: 'terminal',
id: options.id ?? 'tab-1',
title,
terminal: 'pty-1',
isActive: true,
...(options.launchAgent ? { launchAgent: options.launchAgent } : {}),
...(options.agentType === undefined ? {} : { agentStatus: agentStatus(options.agentType) })
}
}
describe('resolveMobileTerminalTabAgentId', () => {
it('uses hook-reported agent identity before title fallback', () => {
expect(resolveMobileTerminalTabAgentId(terminalTab('Terminal', { agentType: 'codex' }))).toBe(
'codex'
)
})
it('uses host launch identity before title fallback', () => {
expect(
resolveMobileTerminalTabAgentId(terminalTab('Terminal', { launchAgent: 'claude' }))
).toBe('claude')
})
it('keeps hook identity authoritative over launch identity', () => {
expect(
resolveMobileTerminalTabAgentId(
terminalTab('Terminal', { agentType: 'codex', launchAgent: 'claude' })
)
).toBe('codex')
})
it('falls back to explicit terminal titles when hook identity is unavailable', () => {
expect(resolveMobileTerminalTabAgentId(terminalTab('✦ Gemini CLI'))).toBe('gemini')
})
it('does not let title fallback override launch identity', () => {
expect(
resolveMobileTerminalTabAgentId(terminalTab('Codex ready', { launchAgent: 'claude' }))
).toBe('claude')
})
it('treats unknown hook identity as unavailable', () => {
expect(
resolveMobileTerminalTabAgentId(terminalTab('✳ investigating', { agentType: 'unknown' }))
).toBeNull()
expect(
resolveMobileTerminalTabAgentId(terminalTab('Codex ready', { agentType: 'unknown' }))
).toBe('codex')
})
})
describe('getMobileSessionTabTitle', () => {
it('strips leading agent decorations when an icon is shown', () => {
expect(getMobileSessionTabTitle(terminalTab('✦ Gemini CLI'))).toBe('Gemini CLI')
})
it('falls back for glyph-only agent titles on mobile', () => {
expect(getMobileSessionTabTitle(terminalTab('✳', { agentType: 'claude' }))).toBe('Terminal')
})
it('strips decorations for launch-owned terminal tabs before hooks arrive', () => {
expect(getMobileSessionTabTitle(terminalTab('✳ working', { launchAgent: 'claude' }))).toBe(
'working'
)
})
it('keeps generic status titles unstripped when no agent identity is known', () => {
expect(getMobileSessionTabTitle(terminalTab('✳ investigating'))).toBe('✳ investigating')
})
it('preserves browser title fallbacks after moving the helper out of the route', () => {
const blankBrowserTab: Extract<MobileSessionTab, { type: 'browser' }> = {
type: 'browser',
id: 'browser-1',
title: '',
url: 'about:blank',
browserWorkspaceId: 'browser-workspace-1',
browserPageId: null,
loading: false,
canGoBack: false,
canGoForward: false,
isActive: true
}
expect(getMobileSessionTabTitle(blankBrowserTab)).toBe('New Browser')
})
})

View File

@ -0,0 +1,57 @@
import { stripLeadingAgentTitleDecorationOrEmpty } from '../../../src/shared/agent-title-decoration'
import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/terminal-title-agent-type'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { TuiAgent } from '../../../src/shared/types'
import { isBlankBrowserUrl } from '../browser/browser-url'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
// Why: tab identity + title cleaning uses the same shared glyph/label maps as
// desktop, so the two platforms do not drift on which titles identify agents.
/**
* Resolve which coding agent a mobile terminal tab is running, for its tab
* icon. Hook-reported `agentType` is the authoritative signal; the OSC title is
* the fallback for sessions without hook status or a host launch identity.
* Returns null when no agent is identified (plain shell / unknown), so the tab
* keeps its text-only label.
*/
export function resolveMobileTerminalTabAgentId(tab: {
title: string
agentStatus?: AgentStatusEntry | null
launchAgent?: TuiAgent
}): string | null {
const hookAgentType = tab.agentStatus?.agentType?.trim()
if (hookAgentType && hookAgentType !== 'unknown') {
return hookAgentType
}
if (tab.launchAgent) {
return tab.launchAgent
}
return resolveExplicitTerminalTitleAgentType(tab.title)
}
export function getMobileSessionTabTitle(tab: MobileSessionTab): string {
if (tab.type === 'browser') {
const title = tab.title.trim()
if (title && !isBlankBrowserUrl(title)) {
return title
}
if (isBlankBrowserUrl(tab.url)) {
return 'New Browser'
}
return 'Browser'
}
if (tab.type === 'markdown') {
return tab.title || 'Markdown'
}
if (tab.type === 'file') {
return tab.title || 'File'
}
// Why: strip the leading agent status glyph (✳ etc.) once the tab shows the
// provider icon. Mobile falls back for glyph-only titles because iOS can
// render the bare status glyph as a stray colored box beside the icon.
if (resolveMobileTerminalTabAgentId(tab)) {
return stripLeadingAgentTitleDecorationOrEmpty(tab.title) || 'Terminal'
}
return tab.title || 'Terminal'
}

View File

@ -3,7 +3,7 @@ import { useSortable } from '@dnd-kit/sortable'
import { X, Minimize2, Pin } from 'lucide-react'
import { ShellIcon } from './shell-icons'
import { AgentIcon } from '@/lib/agent-catalog'
import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration'
import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration'
import { useTabAgent } from '@/lib/use-tab-agent'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'

View File

@ -85,7 +85,7 @@ vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: ({ agent }: { agent: string }) => <span data-agent-catalog-icon={agent} />
}))
vi.mock('@/lib/agent-title-decoration', () => ({
vi.mock('../../../../shared/agent-title-decoration', () => ({
stripLeadingAgentTitleDecoration: (title: string) =>
title.replace(/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/, '').trimStart() || title
}))

View File

@ -2314,11 +2314,15 @@ export function connectPanePty(
let reassertingPtySizeOnResume = false
const reassertPtySizeOnResume = (): void => {
const ptyId = transport.getPtyId()
// forwardPtyResize re-checks the visibility/mobile gates at send time, so
// here we only need the cheap pre-hop early-outs. Skip remote-runtime PTYs:
// their resize goes through a separate viewport channel (not pty:resize), so
// the local ptySizes map getSize reads is never populated for them.
if (disposed || reassertingPtySizeOnResume || !ptyId || isRemoteRuntimePtyId(ptyId)) {
// Skip parked/mobile-driven PTYs before the async size read: their drift
// from desktop xterm dims is intentional until mobile hands control back.
if (
disposed ||
reassertingPtySizeOnResume ||
!ptyId ||
isRemoteRuntimePtyId(ptyId) ||
shouldSuppressDesktopPtyResize()
) {
return
}
reassertingPtySizeOnResume = true

View File

@ -1,6 +1,6 @@
import { useCallback } from 'react'
import { useAppStore } from '@/store'
import { resolveExplicitTerminalTitleAgentType } from '@/lib/terminal-title-agent-type'
import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound'
import { buildAgentNotificationId } from '../../../../shared/agent-notification-id'

View File

@ -11,10 +11,10 @@ import {
resolveSiblingCompletedTabAgent,
resolveSiblingTabAgent
} from './tab-agent'
import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import type { TerminalTab, TuiAgent } from '../../../shared/types'
export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from './terminal-title-agent-type'
export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type'
const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const

View File

@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { stripLeadingAgentTitleDecoration } from './agent-title-decoration'
import {
stripLeadingAgentTitleDecoration,
stripLeadingAgentTitleDecorationOrEmpty
} from './agent-title-decoration'
describe('stripLeadingAgentTitleDecoration', () => {
it("strips Claude's ✳ idle glyph", () => {
@ -24,4 +27,9 @@ describe('stripLeadingAgentTitleDecoration', () => {
expect(stripLeadingAgentTitleDecoration('✳')).toBe('✳')
expect(stripLeadingAgentTitleDecoration('✳ ')).toBe('✳ ')
})
it('can strip to empty when the caller supplies its own fallback label', () => {
expect(stripLeadingAgentTitleDecorationOrEmpty('✳')).toBe('')
expect(stripLeadingAgentTitleDecorationOrEmpty('✳ ')).toBe('')
})
})

View File

@ -7,8 +7,12 @@ const LEADING_AGENT_TITLE_DECORATION_RE =
// eslint-disable-next-line no-control-regex -- intentional unicode status-glyph ranges
/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/
export function stripLeadingAgentTitleDecorationOrEmpty(title: string): string {
return title.replace(LEADING_AGENT_TITLE_DECORATION_RE, '').trimStart()
}
export function stripLeadingAgentTitleDecoration(title: string): string {
const stripped = title.replace(LEADING_AGENT_TITLE_DECORATION_RE, '').trimStart()
const stripped = stripLeadingAgentTitleDecorationOrEmpty(title)
// Why: never return empty — a title that is *only* a status glyph should keep
// its original text rather than collapse to a blank tab label.
return stripped.length > 0 ? stripped : title

View File

@ -1,5 +1,5 @@
import { getAgentLabel, titleHasAgentName } from '../../../shared/agent-detection'
import type { TuiAgent } from '../../../shared/types'
import { getAgentLabel, titleHasAgentName } from './agent-detection'
import type { TuiAgent } from './types'
// Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for
// agents whose foreground PROCESS name isn't self-identifying (Claude Code runs