Files
NetMesh/domain/sessionTabTitle.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

152 lines
6.4 KiB
TypeScript

import type { TerminalSession } from '../types';
import type { CodingCliProviderId } from './codingCliProviders';
import { normalizeCodingCliTitle } from './codingCliTitleParse';
import type { DynamicTabTitleMode } from './models/terminal';
/** Static connection label: user rename or host label. */
export const getSessionConnectionLabel = (session: Pick<TerminalSession, 'customName' | 'hostLabel'>): string => {
return session.customName || session.hostLabel || '';
};
/**
* Default title given to a freshly created split workspace. Used as a sentinel:
* while the workspace still carries this title (i.e. the user has not renamed
* it), the tab derives a label from its sessions instead of showing "Workspace".
*/
export const DEFAULT_WORKSPACE_TITLE = 'Workspace';
/**
* Compose the title for a workspace created by merging standalone session
* tabs (e.g. dragging tab "02" onto tab "01"). The merged workspace inherits
* both tabs' connection labels joined with "/" ("01/02") so the window title
* and the rename dialog show the merged identity instead of the generic
* "Workspace". Returns null when neither session has a label — the caller
* keeps the default auto-titled behavior in that case. Identical labels
* collapse to a single name so two panes on the same host don't read
* "web-01/web-01".
*/
export const buildMergedWorkspaceTitle = (
baseLabel: string,
joiningLabel: string,
): string | null => {
const base = baseLabel.trim();
const joining = joiningLabel.trim();
if (!base && !joining) return null;
if (!base) return joining;
if (!joining || joining === base) return base;
return `${base}/${joining}`;
};
type WorkspaceTabLabelSession = Pick<TerminalSession, 'id' | 'customName' | 'hostLabel' | 'hostId'>;
/**
* Recompute a merged workspace's composed title from its *current* member
* sessions. Unlike the one-shot `buildMergedWorkspaceTitle` call at merge
* time, this runs over live membership, so pane renames and panes being
* added or removed are reflected. Returns null when no member has a label.
* Labels are deduplicated by exact match against the other member labels
* (not against the accumulated title, which would only catch full-string
* repeats like "web/db/web/db"), keeping first-seen order: `web/db/web`
* collapses to `web/db`.
*/
export const buildMergedWorkspaceTitleFromSessions = (
sessions: readonly WorkspaceTabLabelSession[],
): string | null => {
const labels: string[] = [];
for (const session of sessions) {
const label = getSessionConnectionLabel(session).trim();
if (!label || labels.includes(label)) continue;
labels.push(label);
}
if (labels.length === 0) return null;
return labels.join('/');
};
/**
* Resolve the label shown on a split-workspace tab. When the user has renamed
* the workspace, that name wins. Otherwise derive it from the focused session's
* host (e.g. "Localhost" for a local shell, the host config name for SSH) so the
* tab reads as a concrete host instead of the generic "Workspace". If the
* workspace holds sessions for more than one distinct host, the focused host is
* shown with a "+N" suffix for the others.
*
* Uses the stable connection label (rename / host label) rather than the
* dynamic shell/agent title: those live titles are published only to the
* session presentation store (not the plain session records passed here), and
* they can differ per-pane on a single host — counting them would both go stale
* and produce a bogus "+N". The host identity is what this tab is about.
*/
export const resolveWorkspaceTabLabel = (
workspace: { title?: string; focusedSessionId?: string | null; autoTitle?: boolean },
sessions: readonly WorkspaceTabLabelSession[],
): string => {
const title = workspace.title?.trim();
// Derive a host label only for an auto-titled workspace. The `autoTitle` flag
// is authoritative when present (so a workspace a user explicitly named
// "Workspace" is kept, not overwritten); legacy workspaces without the flag
// fall back to matching the default-title string.
const isAutoTitle = workspace.autoTitle ?? (title === DEFAULT_WORKSPACE_TITLE);
if (!isAutoTitle) {
return title || DEFAULT_WORKSPACE_TITLE;
}
if (sessions.length === 0) {
return title || DEFAULT_WORKSPACE_TITLE;
}
const focused = sessions.find((s) => s.id === workspace.focusedSessionId) ?? sessions[0];
const primary = getSessionConnectionLabel(focused);
// Count distinct HOSTS by hostId, the only stable host identity: a rename
// rewrites both customName and hostLabel (useSessionState.submitSessionRename),
// so neither is safe to count by — a renamed pane on the same host would add a
// spurious "+1". hostId is shared across all local terminals and per host
// config, and is untouched by renames. The focused pane may still show its
// rename as the primary label.
const distinctHosts = new Set(sessions.map((s) => s.hostId).filter(Boolean));
if (distinctHosts.size > 1) {
return `${primary} +${distinctHosts.size - 1}`;
}
return primary || title || DEFAULT_WORKSPACE_TITLE;
};
export const shouldUpdateCodingCliTabIcon = (
dynamicTabTitleMode: DynamicTabTitleMode = 'agent',
): boolean => dynamicTabTitleMode !== 'off';
export const resolveCodingCliProviderIconUpdate = ({
dynamicTabTitleMode,
currentProviderId,
nextProviderId,
}: {
dynamicTabTitleMode: DynamicTabTitleMode;
currentProviderId?: CodingCliProviderId;
nextProviderId: CodingCliProviderId | null;
}): CodingCliProviderId | null | undefined => {
if (!shouldUpdateCodingCliTabIcon(dynamicTabTitleMode)) return undefined;
if ((currentProviderId ?? null) === nextProviderId) return undefined;
return nextProviderId;
};
/**
* Resolve the label shown on session tabs and pane headers.
* Uses the shell-reported title according to the global dynamic title mode.
*/
export const resolveSessionTabTitle = (
session: Pick<TerminalSession, 'customName' | 'hostLabel' | 'dynamicTitle' | 'codingCliProviderId'>,
dynamicTabTitleMode: DynamicTabTitleMode = 'agent',
): string => {
const connectionLabel = getSessionConnectionLabel(session);
if (dynamicTabTitleMode === 'off') {
return connectionLabel;
}
if (session.customName) {
return session.customName;
}
if (dynamicTabTitleMode === 'agent' && !session.codingCliProviderId) {
return connectionLabel;
}
const dynamicTitle = session.dynamicTitle?.trim();
if (!dynamicTitle) {
return connectionLabel;
}
return normalizeCodingCliTitle(dynamicTitle) || dynamicTitle;
};