Add tab renaming#269
Conversation
Tabs can now be renamed by double-clicking (or via right-click "Rename Tab"). The custom name is preserved across sessions in layouts.json and survives Ghostty shell title updates. Blocking-script tabs (Archive, Delete, user scripts) are excluded from renaming via the existing isTitleLocked flag. - TerminalTabItem: add customTitle (user override) and displayTitle (computed: customTitle ?? title) - TerminalTabManager: add setCustomTitle(); guard updateTitle() against overwriting a user-set name (customTitle == nil check) - TerminalLayoutSnapshot.TabSnapshot: add customTitle field; Optional ensures zero-migration backward compat with existing layouts.json - WorktreeTerminalState: persist and restore customTitle in captureLayoutSnapshot / restoreFromSnapshot - TerminalTabView: double-tap gesture + inline TextField overlay; Enter commits, Escape/focus-loss guard prevents spurious saves - TerminalTabContextMenu: "Rename Tab" entry for non-locked tabs - Tests: 4 new TerminalTabManagerTests + 2 new TerminalLayoutSnapshotTests covering the rename and persistence paths
There was a problem hiding this comment.
Works overall 💪, but a few things to sort before merge.
The guard in updateTitle means the second field isn't really earning its keep, the rename validation lives in the view instead of the manager, the blocking-script scrub at capture is untested, and the "Change Terminal Title" palette entry currently shows up but is a no-op, we should wire it in this PR.
Command palette wiring
The "Change Terminal Title" entry shows up in the command palette today, cause we pipe Ghostty's native commands through via .ghosttyCommand (see CommandPaletteFeature.swift around line 462/571/672), but it's a no-op: it routes to Ghostty's own title setter, which updateTitle now ignores once a customTitle is set, and it has no path to setCustomTitle at all. So users see the action and nothing happens, which is worse than not having it.
Could we intercept that specific Ghostty command in this PR and route it to the same rename flow (either trigger the inline TextField overlay for the active tab, or open a mini input popover)? Otherwise we ship a broken entry point on day one.
- Remove customTitle guard from updateTitle so Ghostty keeps writing to title even when a custom name is set. displayTitle still shows the custom name via customTitle ?? title. - Move empty/whitespace normalisation into setCustomTitle (String, not String?) so the manager owns the invariant — no caller needs to decide what empty means. - commitRename now always calls onRename(trimmed); empty string bubbles up to the manager which converts it to nil, clearing the custom title and snapping back to the live shell title (natural undo-rename). - Update tests: ghosttyUpdate test now also asserts title is written; nil calls replaced with "" to match the new non-optional signature; new clearingCustomTitleRestoresLiveShellTitle test covers the full flow.
- Route prompt_surface_title / prompt_tab_title to the rename flow with synchronous tabID capture, so a tab switch between dispatch and effect cannot redirect the rename. - Collapse pendingRenameTabID + view @State into a single editingTabID on TerminalTabManager; tabs.didSet drops it across every close path. - Single-source the rename commit on the isEditing transition; .onDisappear commits on view-tree teardown (e.g. worktree swap) and skips when the user pressed Escape or left the buffer unchanged. - setCustomTitle now guards against locked tabs and trims .whitespacesAndNewlines; snapshot capture keys on blockingScripts (not the isTitleLocked || tintColor heuristic). - Add tests for the new contracts: synchronous tabID, no-tabs no-op, same-value pinning, snapshot round-trip with customTitle stripping.
What
Enable renaming of tabs.
You can double tap the label or use the context menu option. I didn't understand the script tabs blocking so I tried not to interfere with any of that. Name is persisted so future sessions keep the custom name.
Disclaimer
I read you prefer issues than vibe coded PRs. I used claude code on this one but manually supervised plan, code and tested manually. I think issue is small enough and a PR is helpfull in this case. Otherwise please let me know.
How it works
Model layer
TerminalTabItemgainscustomTitle: String?and a computeddisplayTitle: String(customTitle ?? title).isTitleLockedis left exclusively for blocking-script tabs — no semantic change.TerminalTabManager.updateTitle()gains acustomTitle == nilguard so Ghostty surface-title callbacks don't overwrite a user rename.TerminalTabManager.setCustomTitle(_:title:)sets/clears the override without touchingisTitleLocked.Persistence
TerminalLayoutSnapshot.TabSnapshotgainscustomTitle: String?. The field isOptionalso existinglayouts.jsonfiles without the key decode tonil— zero migration cost.captureLayoutSnapshot()serialisescustomTitle(nil for blocking-script tabs, same as the existing icon/tintColor normalisation).restoreFromSnapshot()callssetCustomTitleafter recreating the tab if the snapshot carries a custom name.UI
TerminalTabLabelViewrenderstab.displayTitleinstead oftab.title.TerminalTabViewadds a@FocusState-managedTextFieldoverlay that appears on double-tap (TapGesture(count: 2)). Enter commits, Escape cancels, focus loss commits. The label and close button are hidden (opacity: 0 / allowsHitTesting: false) while the field is active so there's no visual bleed-through.WorktreeTerminalTabsView→TerminalTabBarView→TerminalTabsView→TerminalTabsRowViewfollowing the same closure-chain pattern ascloseTab/closeOthers.TerminalTabContextMenushows "Rename Tab" for non-locked tabs, wired toeditingTabIdstate inTerminalTabsRowView.Tests
TerminalTabManagerTests: custom title overrides display title, does not setisTitleLocked, blocks Ghostty updates, clears correctly.TerminalLayoutSnapshotTests:customTitleround-trips through JSON encoding; missing key in legacy JSON decodes asnil.Smoke test checklist
vim→ custom name stays, Ghostty title ignored