diff --git a/shell/shellIntegration-profile.bash b/shell/shellIntegration-profile.bash new file mode 100644 index 0000000..88c853f --- /dev/null +++ b/shell/shellIntegration-profile.bash @@ -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 diff --git a/shell/shellIntegration.bash b/shell/shellIntegration.bash index c494522..a378fd2 100644 --- a/shell/shellIntegration.bash +++ b/shell/shellIntegration.bash @@ -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]}" diff --git a/src/isterm/pty.ts b/src/isterm/pty.ts index 1713795..00f2999 100644 --- a/src/isterm/pty.ts +++ b/src/isterm/pty.ts @@ -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"; @@ -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, }); @@ -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)); @@ -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"; } @@ -416,6 +402,9 @@ export class ISTerm implements IPty { } export const spawn = async (program: Command, options: ISTermOptions): Promise => { + 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 }); @@ -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: diff --git a/src/tests/utils/shell.test.ts b/src/tests/utils/shell.test.ts index e8cce00..170d264 100644 --- a/src/tests/utils/shell.test.ts +++ b/src/tests/utils/shell.test.ts @@ -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"], diff --git a/src/tests/utils/stdioProxy.test.ts b/src/tests/utils/stdioProxy.test.ts index 3cd734b..2271330 100644 --- a/src/tests/utils/stdioProxy.test.ts +++ b/src/tests/utils/stdioProxy.test.ts @@ -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)); @@ -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"]); @@ -28,6 +29,7 @@ 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")); @@ -35,15 +37,98 @@ test("handles cursor-position reports split across chunks", () => { 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(); @@ -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(); diff --git a/src/ui/stdioProxy.ts b/src/ui/stdioProxy.ts index 25dba6c..06bf585 100644 --- a/src/ui/stdioProxy.ts +++ b/src/ui/stdioProxy.ts @@ -8,10 +8,24 @@ import { StringDecoder } from "node:string_decoder"; import * as ansi from "../utils/ansi.js"; import type { KeyPressEvent } from "./suggestionManager.js"; -// eslint-disable-next-line no-control-regex -const cursorPositionReport = new RegExp("\\u001B\\[\\??\\d+;\\d+R", "g"); -// eslint-disable-next-line no-control-regex -const partialCursorPositionReport = new RegExp("\\u001B\\[\\??\\d*(?:;\\d*)?$"); +const ESC = "\u001B"; +const BEL = "\u0007"; +type TerminalResponseKind = "cursor-position" | "color-10" | "color-11" | "color-12"; +type TerminalQuery = { kind: TerminalResponseKind; sequence: string }; +type TerminalSequenceMatch = + | { state: "complete"; kind: TerminalResponseKind; end: number } + | { state: "partial"; kinds: TerminalResponseKind[]; discardOnTimeout: boolean } + | { state: "none" }; + +const terminalResponseLifetime = 1_000; +const terminalQueries: TerminalQuery[] = [ + { kind: "cursor-position", sequence: `${ESC}[6n` }, + { kind: "cursor-position", sequence: `${ESC}[?6n` }, + ...([10, 11, 12] as const).flatMap((color) => [ + { kind: `color-${color}` as const, sequence: `${ESC}]${color};?${BEL}` }, + { kind: `color-${color}` as const, sequence: `${ESC}]${color};?${ESC}\\` }, + ]), +]; // blocks win32 input mode, the kitty keyboard protocol and xterm modifyOtherKeys from upgrading input & breaking node's readline // eslint-disable-next-line no-control-regex const keyEncodingUpgrade = new RegExp("\\u001B\\[(?:\\?9001([hl])|\\?u|[=><][\\d;]*u|>[\\d;]*m)", "g"); @@ -20,8 +34,76 @@ const keyEncodingUpgrade = new RegExp("\\u001B\\[(?:\\?9001([hl])|\\?u|[=><][\\d const partialKeyEncodingUpgrade = new RegExp("^\\u001B(?:\\[(?:\\?(?:9(?:0(?:0(?:1)?)?)?)?|[=><][\\d;]*)?)?$"); const carriageReturn = "\r".charCodeAt(0); +const isDigit = (value: string | undefined): boolean => value != null && value >= "0" && value <= "9"; + +const parseCursorPositionResponse = (input: string, start: number): TerminalSequenceMatch => { + if (input[start + 1] !== "[") return { state: "none" }; + const partial: TerminalSequenceMatch = { state: "partial", kinds: ["cursor-position"], discardOnTimeout: false }; + + let index = start + 2; + if (input[index] === "?") index++; + + const rowStart = index; + while (isDigit(input[index])) index++; + if (index === rowStart) return index === input.length ? partial : { state: "none" }; + if (index === input.length) return partial; + if (input[index] !== ";") return { state: "none" }; + index++; + + const columnStart = index; + while (isDigit(input[index])) index++; + if (index === columnStart) return index === input.length ? partial : { state: "none" }; + if (index === input.length) return partial; + return input[index] === "R" ? { state: "complete", kind: "cursor-position", end: index + 1 } : { state: "none" }; +}; + +const parseColorResponse = (input: string, start: number): TerminalSequenceMatch => { + if (input[start + 1] !== "]") return { state: "none" }; + + const codeStart = start + 2; + const remaining = input.slice(codeStart); + const color = ([10, 11, 12] as const).find((value) => remaining.startsWith(`${value};`)); + if (color == null) { + const possibleColors = ([10, 11, 12] as const).filter((value) => `${value};`.startsWith(remaining)).map((value) => `color-${value}` as const); + return possibleColors.length === 0 ? { state: "none" } : { state: "partial", kinds: possibleColors, discardOnTimeout: false }; + } + + let index = codeStart + `${color};`.length; + while (index < input.length) { + if (input[index] === BEL) return { state: "complete", kind: `color-${color}`, end: index + 1 }; + if (input[index] === ESC) { + if (index + 1 === input.length) return { state: "partial", kinds: [`color-${color}`], discardOnTimeout: true }; + return input[index + 1] === "\\" ? { state: "complete", kind: `color-${color}`, end: index + 2 } : { state: "none" }; + } + index++; + } + return { state: "partial", kinds: [`color-${color}`], discardOnTimeout: true }; +}; + +const parseTerminalResponse = (input: string, start: number): TerminalSequenceMatch => { + if (input[start] !== ESC) return { state: "none" }; + if (start + 1 === input.length) { + return { state: "partial", kinds: ["cursor-position", "color-10", "color-11", "color-12"], discardOnTimeout: false }; + } + return input[start + 1] === "[" ? parseCursorPositionResponse(input, start) : parseColorResponse(input, start); +}; + +const getPartialTerminalQuery = (input: string): string => { + let partial = ""; + for (const { sequence } of terminalQueries) { + const minimum = Math.max(0, input.length - sequence.length + 1); + for (let start = minimum; start < input.length; start++) { + const suffix = input.slice(start); + if (suffix.length > partial.length && suffix.length < sequence.length && sequence.startsWith(suffix)) { + partial = suffix; + } + } + } + return partial; +}; + const getPartialKeyEncodingUpgrade = (input: string): string => { - const sequenceStart = input.lastIndexOf("\u001B"); + const sequenceStart = input.lastIndexOf(ESC); if (sequenceStart === -1) return ""; const suffix = input.slice(sequenceStart); return partialKeyEncodingUpgrade.test(suffix) ? suffix : ""; @@ -43,20 +125,24 @@ const replaceBareLineFeeds = (output: string): string => { }; type StdioProxyOptions = { - onCursorPositionReport?: (data: string) => void; + onTerminalResponse?: (data: string) => void; onWin32InputMode?: (enabled: boolean) => void; }; export class StdioProxy { readonly #keypressInput = new PassThrough(); #decoder = new StringDecoder("utf8"); - readonly #onCursorPositionReport: (data: string) => void; + readonly #onTerminalResponse: (data: string) => void; readonly #onWin32InputMode: (enabled: boolean) => void; + readonly #pendingTerminalQueries = new Map(); + readonly #terminalResponseWaiters = new Set<() => void>(); #pendingInput = ""; + #pendingInputTimer?: NodeJS.Timeout; #pendingOutput = ""; + #pendingQueryOutput = ""; - constructor({ onCursorPositionReport = () => {}, onWin32InputMode = () => {} }: StdioProxyOptions = {}) { - this.#onCursorPositionReport = onCursorPositionReport; + constructor({ onTerminalResponse = () => {}, onWin32InputMode = () => {} }: StdioProxyOptions = {}) { + this.#onTerminalResponse = onTerminalResponse; this.#onWin32InputMode = onWin32InputMode; readline.emitKeypressEvents(this.#keypressInput as unknown as NodeJS.ReadStream); } @@ -66,12 +152,14 @@ export class StdioProxy { } handleInput(data: Buffer | string): void { + this.#clearPendingInputTimer(); const decoded = Buffer.isBuffer(data) ? this.#decoder.write(data) : this.#decoder.end() + data; if (!Buffer.isBuffer(data)) this.#decoder = new StringDecoder("utf8"); this.#routeInput(this.#pendingInput + decoded); } handleOutput(data: string): string { + this.#trackTerminalQueries(data); const input = this.#pendingOutput + data; this.#pendingOutput = getPartialKeyEncodingUpgrade(input); const completeInput = this.#pendingOutput.length === 0 ? input : input.slice(0, -this.#pendingOutput.length); @@ -85,22 +173,132 @@ export class StdioProxy { } dispose(): string { - const remaining = this.#pendingInput + this.#decoder.end(); + this.#clearPendingInputTimer(); + const remaining = this.#decoder.end(); this.#pendingInput = ""; if (remaining.length !== 0) this.#keypressInput.write(remaining); this.#keypressInput.destroy(); const pendingOutput = this.#pendingOutput; this.#pendingOutput = ""; + this.#pendingQueryOutput = ""; + this.#pendingTerminalQueries.clear(); + this.#resolveTerminalResponseWaiters(); return pendingOutput; } - #routeInput(input: string): void { - this.#pendingInput = input.match(partialCursorPositionReport)?.[0] ?? ""; - const completeInput = this.#pendingInput.length === 0 ? input : input.slice(0, -this.#pendingInput.length); - const keypressInput = completeInput.replace(cursorPositionReport, (response) => { - this.#onCursorPositionReport(response); - return ""; + waitForPendingTerminalResponses(): Promise { + this.#pruneTerminalQueries(); + const pending = [...this.#pendingTerminalQueries.values()].flat(); + if (pending.length === 0) return Promise.resolve(); + + const wait = Math.max(...pending.map((createdAt) => createdAt + terminalResponseLifetime - Date.now()), 0); + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + this.#terminalResponseWaiters.delete(finish); + this.#pruneTerminalQueries(); + resolve(); + }; + this.#terminalResponseWaiters.add(finish); + const timer = setTimeout(finish, wait); }); + } + + #routeInput(input: string): void { + this.#pendingInput = ""; + let keypressInput = ""; + let index = 0; + while (index < input.length) { + const sequenceStart = input.indexOf(ESC, index); + if (sequenceStart === -1) { + keypressInput += input.slice(index); + break; + } + + keypressInput += input.slice(index, sequenceStart); + const match = parseTerminalResponse(input, sequenceStart); + if (match.state === "complete") { + const response = input.slice(sequenceStart, match.end); + if (this.#consumeTerminalQuery(match.kind)) this.#onTerminalResponse(response); + index = match.end; + } else if (match.state === "partial") { + if (match.discardOnTimeout || this.#hasPendingTerminalQuery(match.kinds)) { + this.#pendingInput = input.slice(sequenceStart); + this.#schedulePendingInput(match.discardOnTimeout, match.kinds); + break; + } + keypressInput += ESC; + index = sequenceStart + 1; + } else { + keypressInput += ESC; + index = sequenceStart + 1; + } + } if (keypressInput.length !== 0) this.#keypressInput.write(keypressInput); } + + #trackTerminalQueries(data: string): void { + const input = this.#pendingQueryOutput + data; + this.#pendingQueryOutput = getPartialTerminalQuery(input); + const completeInput = this.#pendingQueryOutput.length === 0 ? input : input.slice(0, -this.#pendingQueryOutput.length); + const createdAt = Date.now(); + + for (const { kind, sequence } of terminalQueries) { + let index = completeInput.indexOf(sequence); + while (index !== -1) { + const pending = this.#pendingTerminalQueries.get(kind) ?? []; + pending.push(createdAt); + this.#pendingTerminalQueries.set(kind, pending); + index = completeInput.indexOf(sequence, index + sequence.length); + } + } + } + + #consumeTerminalQuery(kind: TerminalResponseKind): boolean { + this.#pruneTerminalQueries(); + const pending = this.#pendingTerminalQueries.get(kind); + if (pending == null || pending.length === 0) return false; + pending.shift(); + if (pending.length === 0) this.#pendingTerminalQueries.delete(kind); + if (this.#pendingTerminalQueries.size === 0) this.#resolveTerminalResponseWaiters(); + return true; + } + + #hasPendingTerminalQuery(kinds: TerminalResponseKind[]): boolean { + this.#pruneTerminalQueries(); + return kinds.some((kind) => (this.#pendingTerminalQueries.get(kind)?.length ?? 0) > 0); + } + + #schedulePendingInput(discard: boolean, kinds: TerminalResponseKind[]): void { + const pending = kinds.flatMap((kind) => this.#pendingTerminalQueries.get(kind) ?? []); + const wait = + pending.length === 0 ? terminalResponseLifetime : Math.max(...pending.map((createdAt) => createdAt + terminalResponseLifetime - Date.now()), 0); + this.#pendingInputTimer = setTimeout(() => { + const input = this.#pendingInput; + this.#pendingInput = ""; + this.#pendingInputTimer = undefined; + if (!discard && input.length !== 0) this.#keypressInput.write(input); + }, wait); + } + + #clearPendingInputTimer(): void { + if (this.#pendingInputTimer != null) clearTimeout(this.#pendingInputTimer); + this.#pendingInputTimer = undefined; + } + + #pruneTerminalQueries(): void { + const oldest = Date.now() - terminalResponseLifetime; + for (const [kind, pending] of this.#pendingTerminalQueries) { + const active = pending.filter((createdAt) => createdAt > oldest); + if (active.length === 0) { + this.#pendingTerminalQueries.delete(kind); + } else { + this.#pendingTerminalQueries.set(kind, active); + } + } + } + + #resolveTerminalResponseWaiters(): void { + for (const resolve of [...this.#terminalResponseWaiters]) resolve(); + } } diff --git a/src/ui/ui-root.ts b/src/ui/ui-root.ts index 03004f6..6ee3d1c 100644 --- a/src/ui/ui-root.ts +++ b/src/ui/ui-root.ts @@ -27,14 +27,13 @@ export const render = async (program: Command, shell: Shell, underTest: boolean, const renderer = new SuggestionRenderer(term, suggestions, writeOutput); let commandStateVersion = term.getCommandStateVersion(); let backspaceEchoPending = false; + let termExited = false; const stdinStartedInRawMode = process.stdin.isRaw; if (process.stdin.isTTY) process.stdin.setRawMode(true); const stdio = new StdioProxy({ - onCursorPositionReport: (data) => { - if (term.consumeCursorPositionQuery()) { - term.write(data); - } + onTerminalResponse: (data) => { + if (!termExited) term.write(data); }, }); const handleInput = (data: Buffer | string) => stdio.handleInput(data); @@ -79,6 +78,7 @@ export const render = async (program: Command, shell: Shell, underTest: boolean, }); stdio.onKeypress((...keyPress: KeyPressEvent) => { + if (termExited) return; const press = keyPress[1]; if (term.isAlternateBuffer()) { term.write(press.name === "backspace" ? getBackspaceSequence(keyPress, shell) : press.sequence); @@ -99,11 +99,15 @@ export const render = async (program: Command, shell: Shell, underTest: boolean, }); term.onExit(({ exitCode }) => { - process.stdin.removeListener("data", handleInput); - writeOutput(stdio.dispose()); - if (!stdinStartedInRawMode) process.stdin.setRawMode(false); - process.stdout.write(resetToInitialState); - process.exit(exitCode); + termExited = true; + void (async () => { + await stdio.waitForPendingTerminalResponses(); + process.stdin.removeListener("data", handleInput); + writeOutput(stdio.dispose()); + if (!stdinStartedInRawMode) process.stdin.setRawMode(false); + process.stdout.write(resetToInitialState); + process.exit(exitCode); + })(); }); process.stdout.on("resize", () => { diff --git a/src/utils/shell.ts b/src/utils/shell.ts index c99e026..ac924ad 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -53,6 +53,25 @@ export const aliasSupportedShells = [Shell.Bash, Shell.Zsh]; export const userZdotdir = process.env?.ZDOTDIR ?? os.homedir() ?? `~`; export const zdotdir = path.join(os.tmpdir(), `is-zsh-${process.pid}`); +export const bashLoginHome = path.join(os.tmpdir(), `is-bash-${process.pid}`); + +const toBashPath = (filePath: string, platform: NodeJS.Platform): string => { + if (platform !== "win32") return filePath; + return filePath.replaceAll("\\", "/").replace(/^([A-Za-z]):/, (_match, drive: string) => `/${drive.toLowerCase()}`); +}; + +export const getBashLoginEnvironment = ( + env: Record, + loginHome = bashLoginHome, + platform: NodeJS.Platform = process.platform, +): Record => { + const userHome = env.HOME || os.homedir(); + return { + ...env, + HOME: toBashPath(loginHome, platform), + ISTERM_USER_HOME: toBashPath(userHome, platform), + }; +}; export const checkShellConfigs = (): Shell[] => { const shellsWithoutConfigs: Shell[] = []; @@ -161,6 +180,20 @@ const getShellConfigName = (shell: Shell) => { }; let zshDotfilesCleanupRegistered = false; +let bashLoginCleanupRegistered = false; + +export const setupBashLoginShell = async (loginHome = bashLoginHome, resourcesPath = shellResourcesPath) => { + await fsAsync.mkdir(loginHome, { recursive: true }); + if (loginHome === bashLoginHome && !bashLoginCleanupRegistered) { + process.once("exit", () => fs.rmSync(bashLoginHome, { recursive: true, force: true })); + bashLoginCleanupRegistered = true; + } + await Promise.all([ + fsAsync.cp(path.join(resourcesPath, "shellIntegration-profile.bash"), path.join(loginHome, ".bash_profile"), { force: true }), + fsAsync.cp(path.join(resourcesPath, "shellIntegration.bash"), path.join(loginHome, "shellIntegration.bash"), { force: true }), + fsAsync.cp(path.join(resourcesPath, "bash-preexec.sh"), path.join(loginHome, "bash-preexec.sh"), { force: true }), + ]); +}; export const setupZshDotfiles = async () => { await fsAsync.mkdir(zdotdir, { recursive: true });