Skip to content
Draft
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
16 changes: 16 additions & 0 deletions shell/shellIntegration-profile.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
__is_login_home=$HOME
HOME=$ISTERM_USER_HOME
export HOME
unset ISTERM_USER_HOME

if [ -r ~/.bash_profile ]; then
. ~/.bash_profile
elif [ -r ~/.bash_login ]; then
. ~/.bash_login
elif [ -r ~/.profile ]; then
. ~/.profile
fi

ISTERM_LOGIN=1
. "$__is_login_home/shellIntegration.bash"
unset __is_login_home
18 changes: 2 additions & 16 deletions shell/shellIntegration.bash
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
if [ -z "$ISTERM_LOGIN" ]; then
if [ -r ~/.bashrc ]; then
. ~/.bashrc
fi
else
if [ -r /etc/profile ]; then
. /etc/profile
fi
# execute the first that exists
if [ -r ~/.bash_profile ]; then
. ~/.bash_profile
elif [ -r ~/.bash_login ]; then
. ~/.bash_login
elif [ -r ~/.profile ]; then
. ~/.profile
fi
if [ -z "$ISTERM_LOGIN" ] && [ -r ~/.bashrc ]; then
. ~/.bashrc
fi

__is_shell_source="${BASH_SOURCE[0]}"
Expand Down
25 changes: 7 additions & 18 deletions src/isterm/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";

import pty from "node-pty";
import type { IPty, IEvent } from "node-pty";
import { Shell, userZdotdir, zdotdir } from "../utils/shell.js";
import { getBashLoginEnvironment, setupBashLoginShell, Shell, userZdotdir, zdotdir } from "../utils/shell.js";
import { IsTermOscPs, IstermOscPt, IstermPromptStart, IstermPromptEnd } from "../utils/ansi.js";
import xterm from "@xterm/headless";
import type { IBuffer, IBufferCell } from "@xterm/xterm";
Expand Down Expand Up @@ -69,15 +69,15 @@ export class ISTerm implements IPty {
readonly #commandManager: CommandManager;
readonly #shell: Shell;
#pendingData: string[] = [];
#pendingCursorPositionReports = 0;

constructor({ shell, cols, rows, env, shellTarget, shellArgs, underTest, login }: ISTermOptions & { shellTarget: string }) {
const ptyEnv = { ...convertToPtyEnv(shell, underTest, login), ...env };
this.#pty = pty.spawn(shellTarget, shellArgs ?? [], {
name: "xterm-256color",
cols,
rows,
cwd: process.cwd(),
env: { ...convertToPtyEnv(shell, underTest, login), ...env },
env: shell === Shell.Bash && login ? getBashLoginEnvironment(ptyEnv) : ptyEnv,
useConpty: true,
useConptyDll: true,
});
Expand All @@ -90,14 +90,6 @@ export class ISTerm implements IPty {
this.#term = new xterm.Terminal({ allowProposedApi: true, rows, cols });
this.#term.loadAddon(unicode11Addon);
this.#term.unicode.activeVersion = "11";
this.#term.parser.registerCsiHandler({ final: "n" }, (params) => {
if (params.at(0) === 6) this.#pendingCursorPositionReports += 1;
return false;
});
this.#term.parser.registerCsiHandler({ prefix: "?", final: "n" }, (params) => {
if (params.at(0) === 6) this.#pendingCursorPositionReports += 1;
return false;
});

this.#ptyEmitter = new EventEmitter();
this.#term.parser.registerOscHandler(IsTermOscPs, (data) => this._handleIsSequence(data));
Expand Down Expand Up @@ -238,12 +230,6 @@ export class ISTerm implements IPty {
return this.#commandManager.getStateVersion();
}

consumeCursorPositionQuery(): boolean {
if (this.#pendingCursorPositionReports === 0) return false;
this.#pendingCursorPositionReports -= 1;
return true;
}

isAlternateBuffer(): boolean {
return this.#term.buffer.active.type === "alternate";
}
Expand Down Expand Up @@ -416,6 +402,9 @@ export class ISTerm implements IPty {
}

export const spawn = async (program: Command, options: ISTermOptions): Promise<ISTerm> => {
if (options.shell === Shell.Bash && options.login) {
await setupBashLoginShell();
}
const { shellTarget, shellArgs } = await convertToPtyTarget(options.shell, options.underTest, options.login);
if (!(await shellExists(shellTarget))) {
program.error(`shell not found on PATH: ${shellTarget}`, { exitCode: 1 });
Expand All @@ -436,7 +425,7 @@ const convertToPtyTarget = async (shell: Shell, underTest: boolean, login: boole

switch (shell) {
case Shell.Bash:
shellArgs = ["--init-file", path.join(shellResourcesPath, "shellIntegration.bash")];
shellArgs = login ? ["--login"] : ["--init-file", path.join(shellResourcesPath, "shellIntegration.bash")];
break;
case Shell.Powershell:
case Shell.Pwsh:
Expand Down
37 changes: 36 additions & 1 deletion src/tests/utils/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,47 @@
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { getShellSourceCommand, hasLegacyShellConfig, Shell, shouldFlagLegacyResourcePlugin, zdotdir } from "../../utils/shell.js";
import fs from "node:fs/promises";
import {
bashLoginHome,
getBashLoginEnvironment,
getShellSourceCommand,
hasLegacyShellConfig,
setupBashLoginShell,
Shell,
shouldFlagLegacyResourcePlugin,
zdotdir,
} from "../../utils/shell.js";

test("uses a process-specific ZDOTDIR", () => {
expect(zdotdir).toBe(path.join(os.tmpdir(), `is-zsh-${process.pid}`));
});

test("uses a process-specific Bash login home", () => {
expect(bashLoginHome).toBe(path.join(os.tmpdir(), `is-bash-${process.pid}`));
});

test("preserves the user home while starting Bash from the login trampoline", () => {
expect(getBashLoginEnvironment({ HOME: "/home/user" }, "/tmp/is-bash", "linux")).toMatchObject({
HOME: "/tmp/is-bash",
ISTERM_USER_HOME: "/home/user",
});
expect(getBashLoginEnvironment({ HOME: "C:\\Users\\user" }, "C:\\Temp\\is-bash", "win32")).toMatchObject({
HOME: "/c/Temp/is-bash",
ISTERM_USER_HOME: "/c/Users/user",
});
});

test("creates the Bash login trampoline with its integration dependencies", async () => {
const loginHome = await fs.mkdtemp(path.join(os.tmpdir(), "is-bash-test-"));
try {
await setupBashLoginShell(loginHome, path.resolve("shell"));
await expect(fs.readdir(loginHome)).resolves.toEqual(expect.arrayContaining([".bash_profile", "bash-preexec.sh", "shellIntegration.bash"]));
} finally {
await fs.rm(loginHome, { recursive: true, force: true });
}
});

describe("getShellSourceCommand", () => {
test.each([
[Shell.Bash, "~/.inshellisense/init/bash/init.sh", "[ -f ~/.inshellisense/init/bash/init.sh ] && source ~/.inshellisense/init/bash/init.sh"],
Expand Down
96 changes: 95 additions & 1 deletion src/tests/utils/stdioProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const createRouter = () => {
const keypresses: string[] = [];
const toggles: boolean[] = [];
const proxy = new StdioProxy({
onCursorPositionReport: (data) => responses.push(data),
onTerminalResponse: (data) => responses.push(data),
onWin32InputMode: (enabled) => toggles.push(enabled),
});
proxy.onKeypress((_value, key) => keypresses.push(key.sequence));
Expand All @@ -19,6 +19,7 @@ const createRouter = () => {
test("consumes cursor-position reports without creating keypresses", () => {
const { keypresses, responses, proxy } = createRouter();

proxy.handleOutput("\u001B[6n");
proxy.handleInput(Buffer.from("\u001B[2;7R"));

expect(responses).toEqual(["\u001B[2;7R"]);
Expand All @@ -28,22 +29,106 @@ test("consumes cursor-position reports without creating keypresses", () => {
test("handles cursor-position reports split across chunks", () => {
const { keypresses, responses, proxy } = createRouter();

proxy.handleOutput("\u001B[6n");
proxy.handleInput(Buffer.from("\u001B[2;"));
proxy.handleInput(Buffer.from("7R"));

expect(responses).toEqual(["\u001B[2;7R"]);
expect(keypresses).toEqual([]);
});

test("handles cursor-position reports split after escape", () => {
const { keypresses, responses, proxy } = createRouter();

proxy.handleOutput("\u001B[6n");
proxy.handleInput(Buffer.from("\u001B"));
proxy.handleInput(Buffer.from("[2;7R"));

expect(responses).toEqual(["\u001B[2;7R"]);
expect(keypresses).toEqual([]);
});

test("consumes private cursor-position reports", () => {
const { keypresses, responses, proxy } = createRouter();

proxy.handleOutput("\u001B[?6n");
proxy.handleInput(Buffer.from("\u001B[?2;7R"));

expect(responses).toEqual(["\u001B[?2;7R"]);
expect(keypresses).toEqual([]);
});

test.each([
["foreground", "\u001B]10;?\u0007", "\u001B]10;rgb:ffff/ffff/ffff\u0007"],
["background", "\u001B]11;?\u0007", "\u001B]11;rgb:0000/0000/0000\u0007"],
["cursor", "\u001B]12;?\u001B\\", "\u001B]12;rgb:ffff/ffff/ffff\u001B\\"],
])("routes requested %s color reports without creating keypresses", (_name, query, response) => {
const { keypresses, responses, proxy } = createRouter();

expect(proxy.handleOutput(query)).toBe(query);
proxy.handleInput(Buffer.from(response));

expect(responses).toEqual([response]);
expect(keypresses).toEqual([]);
});

test("handles color queries and reports split across chunks", () => {
const { keypresses, responses, proxy } = createRouter();
const response = "\u001B]11;rgb:0000/0000/0000\u001B\\";

expect(proxy.handleOutput("\u001B]11;")).toBe("\u001B]11;");
expect(proxy.handleOutput("?\u001B\\")).toBe("?\u001B\\");
proxy.handleInput(Buffer.from("\u001B]11;rgb:0000/"));
proxy.handleInput(Buffer.from("0000/0000\u001B"));
proxy.handleInput(Buffer.from("\\"));

expect(responses).toEqual([response]);
expect(keypresses).toEqual([]);
});

test("handles color reports split after escape", () => {
const { keypresses, responses, proxy } = createRouter();
const response = "\u001B]11;rgb:0000/0000/0000\u0007";

proxy.handleOutput("\u001B]11;?\u0007");
proxy.handleInput(Buffer.from("\u001B"));
proxy.handleInput(Buffer.from("]11;rgb:0000/0000/0000\u0007"));

expect(responses).toEqual([response]);
expect(keypresses).toEqual([]);
});

test("discards unsolicited color reports instead of treating them as keypresses", () => {
const { keypresses, responses, proxy } = createRouter();

proxy.handleInput(Buffer.from("\u001B]11;rgb:0000/0000/0000\u0007"));

expect(responses).toEqual([]);
expect(keypresses).toEqual([]);
});

test("routes only the color report matching a pending query", () => {
const { keypresses, responses, proxy } = createRouter();
const background = "\u001B]11;rgb:0000/0000/0000\u0007";

proxy.handleOutput("\u001B]11;?\u0007");
proxy.handleInput(Buffer.from("\u001B]10;rgb:ffff/ffff/ffff\u0007"));
proxy.handleInput(Buffer.from(background));

expect(responses).toEqual([background]);
expect(keypresses).toEqual([]);
});

test("finishes draining as soon as pending terminal reports arrive", async () => {
const { proxy } = createRouter();
proxy.handleOutput("\u001B]11;?\u0007");

const drained = proxy.waitForPendingTerminalResponses();
proxy.handleInput(Buffer.from("\u001B]11;rgb:0000/0000/0000\u0007"));

await expect(drained).resolves.toBeUndefined();
});

test("keeps regular CSI key sequences in readline", () => {
const { keypresses, responses, proxy } = createRouter();

Expand All @@ -53,6 +138,15 @@ test("keeps regular CSI key sequences in readline", () => {
expect(keypresses).toEqual(["\u001B[A"]);
});

test("keeps alt closing-bracket in readline when no color report is pending", () => {
const { keypresses, responses, proxy } = createRouter();

proxy.handleInput(Buffer.from("\u001B]"));

expect(responses).toEqual([]);
expect(keypresses).toEqual(["\u001B]"]);
});

test("captures outbound Win32 input mode toggles", () => {
const { proxy, toggles } = createRouter();

Expand Down
Loading
Loading