Skip to content

Commit d27963a

Browse files
committed
fix(terminal): reset stale title when child process exits
When a child process (e.g. claude) sets a custom terminal title via escape sequences and then exits, shells like zsh on macOS do not emit a title reset. The flyout tab was stuck showing the old title. Reset inst.title to the shell profile name when the foreground process returns to the shell. Also fix _isShellProcess to handle login-shell prefixes like "-zsh", and skip the cwd-in-title check on macOS where zsh does not set it.
1 parent f52f324 commit d27963a

2 files changed

Lines changed: 130 additions & 7 deletions

File tree

src/extensionsIntegrated/Terminal/main.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ define(function (require, exports, module) {
7272
if (!processName) {
7373
return true;
7474
}
75-
const basename = processName.split("/").pop().split("\\").pop();
75+
// Strip path and leading "-" for login shells (e.g. "-zsh")
76+
const basename = processName.split("/").pop().split("\\").pop().replace(/^-/, "");
7677
return SHELL_NAMES.has(basename);
7778
}
7879

@@ -409,6 +410,17 @@ define(function (require, exports, module) {
409410
const newProc = result.process || "";
410411
if (processInfo[id] !== newProc) {
411412
processInfo[id] = newProc;
413+
// When a child process (e.g. "claude") exits and the
414+
// shell regains foreground, the child may have set a
415+
// custom terminal title via escape sequences. Shells
416+
// like zsh on macOS do not emit a title reset, so
417+
// inst.title stays stale. Reset it only when the
418+
// foreground process returns to the shell. If the
419+
// shell does emit a title change, onTitleChange will
420+
// overwrite this immediately.
421+
if (_isShellProcess(newProc)) {
422+
instance.title = instance.shellProfile.name;
423+
}
412424
_updateFlyout();
413425
}
414426
}).catch(function () {

test/spec/Terminal-integ-test.js

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ define(function (require, exports, module) {
3030
const Strings = require("strings");
3131

3232
const IS_WINDOWS = Phoenix.platform === "win";
33+
const IS_MAC = Phoenix.platform === "mac";
3334

3435
describe("integration:Terminal", function () {
3536
let testWindow,
@@ -209,19 +210,19 @@ define(function (require, exports, module) {
209210
.is(":visible")).toBeTrue();
210211
expect(getTerminalCount()).toBe(1);
211212

212-
if (IS_WINDOWS) {
213+
if (IS_WINDOWS || IS_MAC) {
213214
// On Windows, PowerShell/cmd set the title to
214-
// their own executable path rather than
215-
// "user@host: /path/to/cwd". Verify the shell
216-
// started and updated its title from the default
217-
// profile name.
215+
// their own executable path. On macOS, zsh does
216+
// not emit title escape sequences by default.
217+
// Just verify the shell started and updated its
218+
// title from the default profile name.
218219
await waitForShellReady();
219220
const flyoutTitle = testWindow.$(
220221
".terminal-flyout-item.active"
221222
).attr("title") || "";
222223
expect(flyoutTitle).not.toBe("");
223224
} else {
224-
// On Linux/macOS, bash/zsh set the title to
225+
// On Linux, bash/zsh set the title to
225226
// include the cwd (e.g. "user@host: /path").
226227
const expectedPath = getNativeProjectPath();
227228
const projectDirName = expectedPath
@@ -430,6 +431,116 @@ define(function (require, exports, module) {
430431
});
431432
});
432433

434+
describe("Terminal title management", function () {
435+
it("should retain custom title while child process runs",
436+
async function () {
437+
await openTerminal();
438+
await awaitsFor(function () {
439+
return getTerminalCount() === 1;
440+
}, "terminal to be created", 10000);
441+
442+
await waitForShellReady();
443+
444+
// Start a long-running node process that sets a
445+
// custom terminal title via escape sequence.
446+
await writeToTerminal(
447+
'node -e "process.stdout.write('
448+
+ "'\\x1b]0;MyAppTitle\\x07'"
449+
+ ');setTimeout(()=>{},60000)"\r'
450+
);
451+
await waitForActiveProcess("node");
452+
453+
// Verify the custom title appears
454+
await awaitsFor(function () {
455+
triggerFlyoutRefresh();
456+
const title = testWindow.$(
457+
".terminal-flyout-item.active"
458+
).attr("title") || "";
459+
return title.indexOf("MyAppTitle") !== -1;
460+
}, "custom title to appear", 10000);
461+
462+
// Trigger several flyout refreshes — the title
463+
// must remain stable while the process runs.
464+
triggerFlyoutRefresh();
465+
const title = testWindow.$(
466+
".terminal-flyout-item.active"
467+
).attr("title") || "";
468+
expect(title).toContain("MyAppTitle");
469+
470+
// Kill the node process so next test starts clean
471+
await writeToTerminal("\x03"); // Ctrl+C
472+
await awaitsFor(function () {
473+
triggerFlyoutRefresh();
474+
const label = testWindow.$(
475+
".terminal-flyout-item.active "
476+
+ ".terminal-flyout-title"
477+
).text();
478+
return label && label.indexOf("node") === -1;
479+
}, "node process to exit", 10000);
480+
481+
// Clean up
482+
clickPanelCloseButton();
483+
await awaitsFor(function () {
484+
return !testWindow.$("#terminal-panel")
485+
.is(":visible");
486+
}, "terminal panel to close", 5000);
487+
});
488+
489+
it("should clear stale title after child process exits",
490+
async function () {
491+
await openTerminal();
492+
await awaitsFor(function () {
493+
return getTerminalCount() === 1;
494+
}, "terminal to be created", 10000);
495+
496+
await waitForShellReady();
497+
498+
// Start a node process that sets a custom title
499+
// then exits after a short delay.
500+
await writeToTerminal(
501+
'node -e "process.stdout.write('
502+
+ "'\\x1b]0;TestCustomTitle\\x07'"
503+
+ ');setTimeout(()=>{},3000)"\r'
504+
);
505+
await waitForActiveProcess("node");
506+
507+
// Verify the custom title appears in the flyout
508+
await awaitsFor(function () {
509+
triggerFlyoutRefresh();
510+
const title = testWindow.$(
511+
".terminal-flyout-item.active"
512+
).attr("title") || "";
513+
return title.indexOf("TestCustomTitle") !== -1;
514+
}, "custom title to appear", 10000);
515+
516+
// Wait for the node process to exit (3s timeout)
517+
// and the flyout to reflect the shell again.
518+
await awaitsFor(function () {
519+
triggerFlyoutRefresh();
520+
const title = testWindow.$(
521+
".terminal-flyout-item.active"
522+
).attr("title") || "";
523+
return title.indexOf("TestCustomTitle") === -1;
524+
}, "stale title to be cleared after exit", 15000);
525+
526+
// The flyout label should be back to the shell
527+
triggerFlyoutRefresh();
528+
const label = testWindow.$(
529+
".terminal-flyout-item.active "
530+
+ ".terminal-flyout-title"
531+
).text();
532+
expect(label).not.toBe("");
533+
expect(label).not.toContain("node");
534+
535+
// Clean up
536+
clickPanelCloseButton();
537+
await awaitsFor(function () {
538+
return !testWindow.$("#terminal-panel")
539+
.is(":visible");
540+
}, "terminal panel to close", 5000);
541+
});
542+
});
543+
433544
describe("Programmatic hide vs user close", function () {
434545
it("should keep terminals alive after panel.hide()",
435546
async function () {

0 commit comments

Comments
 (0)