@light4/pi-shell-bridge 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 light4
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # pi-shell-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Pi extension that connects Pi's `!` / `!!` commands to your shell:
4
+
5
+ - **Tab completion** — on a `!cmd` line, `Tab` asks your shell for completions (commands, subcommands, options, paths, git branches, …) with their descriptions.
6
+ - **Autosuggestions** — like fish, typing a `!` command shows the newest matching command from your shell history in grey after the cursor; `→` accepts it.
7
+ - **Shell history** — every `!` / `!!` command is appended to the fish, zsh and bash history you already keep, so it shows up in `↑`, `Ctrl+R`, atuin imports and so on.
8
+
9
+ Commands still run through Pi's built-in bash; this extension only adds completion and history.
10
+
11
+ ## Supported shells
12
+
13
+ | Shell | Completion | History |
14
+ |---|---|---|
15
+ | fish | `complete -C` (full fish completions and descriptions) | `history append`, honouring `fish_history` |
16
+ | zsh | the zsh completion system, captured through `zsh/zpty` with your `.zshrc` loaded | `print -s` + `fc -AI` into your `HISTFILE`, respecting `EXTENDED_HISTORY` |
17
+ | bash | `complete -p` specs, loading [bash-completion](https://github.com/scop/bash-completion) when it is installed | `history -s` + `history -a` into your `HISTFILE`, respecting `HISTTIMEFORMAT` |
18
+
19
+ Each shell writes its own history file, so locking, timestamps, multi-line commands and zsh's metafied encoding are handled by the shell itself. Helper shells never record their own commands in your history.
20
+
21
+ ## Use
22
+
23
+ - Type `!git chec` and press `Tab`. A single match is inserted directly; several matches open Pi's completion list. Directories and `--option=` values do not get a trailing space, so you can keep completing.
24
+ - Type `!pi` and, if you ran `pi update --extensions` before, ` update --extensions` appears dimmed after the cursor, as in fish. It is only a suggestion: `Enter` runs what you typed. `→`, `End`, `Ctrl+F` or `Ctrl+E` accept the whole suggestion; `Alt+→` or `Alt+F` accept one word. Suggestions come from the launching shell's history file and the `!` commands of the current session, newest first; they appear only while the cursor is at the end of a single-line `!` command and no completion list is open. The keys follow Pi's `tui.editor.cursorRight`, `cursorLineEnd` and `cursorWordRight` keybindings.
25
+ - Run any `!` or `!!` command. It is added to the history of each configured shell. Open fish shells see it after `history merge` (or in new sessions); zsh with `SHARE_HISTORY` picks it up automatically.
26
+
27
+ The shell is started only when you press `Tab`, never on every keystroke. Completion takes roughly 0.1–0.3 s for fish and bash and 0.5–1 s for zsh, depending on your startup files.
28
+
29
+ ## Configure
30
+
31
+ Defaults work without configuration:
32
+
33
+ - **completion** `auto`: the shell Pi was launched from (its nearest shell ancestor); if that shell is not supported, `$SHELL`, then the first installed of fish, zsh, bash.
34
+ - **history** `auto`: only the shell Pi was launched from, and only when its history file already exists. When Pi is started from an unsupported shell (`nu`, `sh`, `dash`, …) nothing is written; when no shell ancestor exists (Pi launched directly by a terminal or IDE), `$SHELL` is used. zsh without a `HISTFILE` is skipped.
35
+
36
+ Helper shells run in their own session without a controlling terminal, so interactive startup files (`bash -i`, `zsh -i`) can never take over Pi's terminal, whichever shell Pi was started from.
37
+
38
+ To change them, create `~/.pi/agent/pi-shell-bridge.json`:
39
+
40
+ ```json
41
+ {
42
+ "completion": "zsh",
43
+ "history": ["zsh", "fish"],
44
+ "autosuggest": true,
45
+ "loadUserConfig": true,
46
+ "ignore": ["^export .*TOKEN", "password"]
47
+ }
48
+ ```
49
+
50
+ | Key | Values | Default |
51
+ |---|---|---|
52
+ | `completion` | `auto`, `fish`, `zsh`, `bash`, `off` | `auto` |
53
+ | `history` | `auto` (launching shell), `off`, or a list of `fish` / `zsh` / `bash` | `auto` |
54
+ | `autosuggest` | Fish-style inline suggestions on `!` lines (`true` / `false`) | `true` |
55
+ | `loadUserConfig` | Load your shell startup files for completion and to discover `HISTFILE` | `true` |
56
+ | `ignore` | Regular expressions; matching commands are not written to history | `[]` |
57
+
58
+ Environment variables override the file for one Pi launch:
59
+
60
+ ```sh
61
+ PI_SHELL_BRIDGE_COMPLETION=bash PI_SHELL_BRIDGE_HISTORY=fish,zsh pi
62
+ PI_SHELL_BRIDGE_HISTORY=off pi
63
+ PI_SHELL_BRIDGE_AUTOSUGGEST=off pi
64
+ ```
65
+
66
+ Autosuggestions replace Pi's input editor with a subclass of `CustomEditor`, keeping every Pi keybinding. If another extension has already replaced the editor, pi-shell-bridge leaves it alone and shows no suggestions; set `"autosuggest": false` to never touch the editor.
67
+
68
+ Pi trims the command after `!`, so the usual "leading space skips history" convention cannot apply; use `ignore` instead.
69
+
70
+ ## Install
71
+
72
+ Install the v0.1.4 release from npm:
73
+
74
+ ```sh
75
+ pi install npm:@light4/pi-shell-bridge@0.1.4
76
+ ```
77
+
78
+ Or install the same release from GitHub: `pi install git:github.com/light4/pi-shell-bridge@v0.1.4`.
79
+
80
+ Restart Pi or run `/reload` afterwards.
81
+
82
+ ## Install for local development
83
+
84
+ Pi auto-discovers an extension whose directory contains `index.ts`:
85
+
86
+ ```sh
87
+ ln -sfn ~/sources/pi-shell-bridge/src ~/.pi/agent/extensions/pi-shell-bridge
88
+ ```
89
+
90
+ Or load it for a single run:
91
+
92
+ ```sh
93
+ pi -e ~/sources/pi-shell-bridge/src/index.ts
94
+ ```
95
+
96
+ Type-check with:
97
+
98
+ ```sh
99
+ pnpm install
100
+ pnpm run typecheck
101
+ ```
102
+
103
+ ## End-to-end tests
104
+
105
+ `e2e/test_e2e.py` (Python standard library only, run through [uv](https://docs.astral.sh/uv/)) starts real interactive fish, zsh, bash, sh, dash and nu shells in a pseudo terminal, launches `pi` with this extension from each, and checks that Pi stays in the terminal foreground (is never stopped by job control), that `Tab` completes `!` lines and that `!` commands reach the expected history files. Every test uses a temporary `HOME` and Pi agent dir, so your own history and settings are untouched; missing shells are skipped.
106
+
107
+ ```sh
108
+ pnpm run test:e2e # or: uv run e2e/test_e2e.py
109
+ E2E_SHELLS=fish,zsh uv run e2e/test_e2e.py # only these shells
110
+ E2E_EXTENSION=/path/to/src/index.ts PI_BIN=/path/to/pi E2E_VERBOSE=1 uv run e2e/test_e2e.py
111
+ ```
112
+
113
+ ## Acknowledgements
114
+
115
+ The zsh capture technique follows [Valodim/zsh-capture-completion](https://github.com/Valodim/zsh-capture-completion) (MIT).
116
+
117
+ ## License
118
+
119
+ MIT
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@light4/pi-shell-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.4",
4
+ "packageManager": "pnpm@12.8.1",
5
+ "description": "Shell completion, fish-style autosuggestions and fish/zsh/bash history for Pi's ! commands",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "shell",
11
+ "completion",
12
+ "history",
13
+ "autosuggestion",
14
+ "fish",
15
+ "zsh",
16
+ "bash"
17
+ ],
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/light4/pi-shell-bridge.git"
22
+ },
23
+ "files": [
24
+ "src"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "scripts": {
35
+ "typecheck": "tsc -p tsconfig.json",
36
+ "test:e2e": "uv run e2e/test_e2e.py"
37
+ },
38
+ "engines": {
39
+ "node": ">=22.19.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*"
44
+ },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-coding-agent": "^1.5.0",
47
+ "@earendil-works/pi-tui": "^1.5.0",
48
+ "@types/node": "^22.20.5",
49
+ "typescript": "^7.0.2"
50
+ }
51
+ }
@@ -0,0 +1,100 @@
1
+ // Fish-style autosuggestions for `!` lines: the newest matching history command is drawn dimmed after the
2
+ // cursor. It is not part of the editor text; → / End / Ctrl+F accept it, Alt+→ / Alt+F accept one word.
3
+
4
+ import { CustomEditor, type ExtensionUIContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
5
+ import { type EditorTheme, renderFakeCursor, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
+ import type { HistoryIndex } from "./history.ts";
7
+
8
+ type EditorFactory = NonNullable<Parameters<ExtensionUIContext["setEditorComponent"]>[0]>;
9
+
10
+ const END_CURSOR = renderFakeCursor(" ");
11
+ const BANG_LINE = /^(\s*!!?\s*)(.*)$/s;
12
+
13
+ /** The command typed after `!` / `!!`, or undefined when the text is not a bash-mode line. */
14
+ export function bangCommand(text: string): string | undefined {
15
+ const match = text.match(BANG_LINE);
16
+ return match ? match[2] : undefined;
17
+ }
18
+
19
+ /** The first word of a suggestion, with the whitespace before it (fish: Alt+→). */
20
+ export function firstWord(suffix: string): string {
21
+ return suffix.match(/^\s*\S+/)?.[0] ?? suffix;
22
+ }
23
+
24
+ /**
25
+ * Replace the end-of-line fake cursor and the padding after it with `suffix`: the cursor sits on the
26
+ * first suggested character, the rest is styled with `dim`. Lines without that cursor are returned unchanged.
27
+ */
28
+ export function drawSuggestion(line: string, suffix: string, paddingX: number, dim: (text: string) => string): string {
29
+ const at = line.lastIndexOf(END_CURSOR);
30
+ if (at === -1) return line;
31
+ const after = line.slice(at + END_CURSOR.length);
32
+ if (!/^ *$/.test(after)) return line;
33
+ const room = 1 + Math.max(0, after.length - paddingX); // the cursor cell plus the padding up to the margin
34
+ const shown = truncateToWidth(suffix, room, "");
35
+ const graphemes = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(shown)];
36
+ const head = graphemes[0]?.segment;
37
+ if (!head || visibleWidth(head) !== 1) return line; // keep the one-cell cursor layout intact
38
+ const rest = shown.slice(head.length);
39
+ const used = visibleWidth(shown);
40
+ return line.slice(0, at) + renderFakeCursor(dim(head)) + dim(rest) + " ".repeat(Math.max(0, after.length + 1 - used));
41
+ }
42
+
43
+ export class AutosuggestEditor extends CustomEditor {
44
+ constructor(
45
+ tui: TUI,
46
+ theme: EditorTheme,
47
+ private readonly keys: KeybindingsManager,
48
+ private readonly commands: HistoryIndex,
49
+ private readonly dim: (text: string) => string,
50
+ ) {
51
+ super(tui, theme, keys);
52
+ }
53
+
54
+ /** The suggested completion of the current line, when the cursor is at the end of a single-line `!` command. */
55
+ suggestion(): string | undefined {
56
+ if (this.isShowingAutocomplete()) return undefined;
57
+ const lines = this.getLines();
58
+ if (lines.length !== 1) return undefined;
59
+ const cursor = this.getCursor();
60
+ if (cursor.line !== 0 || cursor.col !== lines[0].length) return undefined;
61
+ const command = bangCommand(lines[0]);
62
+ return command === undefined ? undefined : this.commands.suggest(command);
63
+ }
64
+
65
+ override render(width: number): string[] {
66
+ const lines = super.render(width);
67
+ try {
68
+ const suffix = this.suggestion();
69
+ if (!suffix) return lines;
70
+ const index = lines.findIndex((line) => line.includes(END_CURSOR));
71
+ if (index !== -1) lines[index] = drawSuggestion(lines[index], suffix, this.getPaddingX(), this.dim);
72
+ } catch {
73
+ // Never break the editor over a suggestion.
74
+ }
75
+ return lines;
76
+ }
77
+
78
+ override handleInput(data: string): void {
79
+ const suffix = this.acceptable(data);
80
+ if (suffix) {
81
+ this.insertTextAtCursor(suffix);
82
+ return;
83
+ }
84
+ super.handleInput(data);
85
+ }
86
+
87
+ private acceptable(data: string): string | undefined {
88
+ const keys = this.keys;
89
+ const whole = keys.matches(data, "tui.editor.cursorRight") || keys.matches(data, "tui.editor.cursorLineEnd");
90
+ const word = keys.matches(data, "tui.editor.cursorWordRight");
91
+ if (!whole && !word) return undefined;
92
+ const suffix = this.suggestion();
93
+ if (!suffix) return undefined;
94
+ return whole ? suffix : firstWord(suffix);
95
+ }
96
+ }
97
+
98
+ export function autosuggestEditorFactory(history: HistoryIndex, dim: (text: string) => string): EditorFactory {
99
+ return (tui, theme, keybindings) => new AutosuggestEditor(tui, theme, keybindings, history, dim);
100
+ }
@@ -0,0 +1,77 @@
1
+ # Print bash completions for the command line in $1, one per line.
2
+ # Run as: bash -i bash-complete.bash <command-line> [marker] (loads ~/.bashrc)
3
+ # Everything printed before the marker comes from user startup files and is ignored by the caller.
4
+ # `bash -i` records script lines in history; never let them reach the user's history file.
5
+ set +o history
6
+ unset HISTFILE
7
+ __pi_shell_bridge_line=$1
8
+ printf '%s\n' "${2:-__PI_SHELL_BRIDGE_READY__}"
9
+
10
+ __pi_shell_bridge_print() {
11
+ local item
12
+ while IFS= read -r item; do
13
+ [[ -z $item ]] && continue
14
+ if [[ $item != */ && -d ${item/#\~/$HOME} ]]; then item+=/; fi
15
+ printf '%s\n' "$item"
16
+ done
17
+ }
18
+
19
+ # Load bash-completion when the user's startup files did not.
20
+ if ! declare -F _comp_load >/dev/null && ! declare -F _completion_loader >/dev/null; then
21
+ for __pi_shell_bridge_bc in \
22
+ "${BASH_COMPLETION_PATH:-}" \
23
+ "$HOME/.nix-profile/share/bash-completion/bash_completion" \
24
+ "/etc/profiles/per-user/$USER/share/bash-completion/bash_completion" \
25
+ /run/current-system/sw/share/bash-completion/bash_completion \
26
+ /opt/homebrew/share/bash-completion/bash_completion \
27
+ /usr/local/share/bash-completion/bash_completion \
28
+ /usr/share/bash-completion/bash_completion \
29
+ /etc/bash_completion; do
30
+ if [[ -n $__pi_shell_bridge_bc && -r $__pi_shell_bridge_bc ]]; then
31
+ # shellcheck disable=SC1090
32
+ source "$__pi_shell_bridge_bc" >/dev/null 2>&1
33
+ break
34
+ fi
35
+ done
36
+ fi
37
+
38
+ COMP_LINE=$__pi_shell_bridge_line
39
+ COMP_POINT=${#COMP_LINE}
40
+ COMP_TYPE=9
41
+ COMP_KEY=9
42
+ read -ra COMP_WORDS <<<"$COMP_LINE"
43
+ if [[ -z $COMP_LINE || $COMP_LINE =~ [[:space:]]$ ]]; then COMP_WORDS+=(""); fi
44
+ COMP_CWORD=$((${#COMP_WORDS[@]} - 1))
45
+ __pi_shell_bridge_cur=${COMP_WORDS[COMP_CWORD]}
46
+
47
+ if ((COMP_CWORD == 0)); then
48
+ compgen -A command -A alias -A function -A builtin -A keyword -- "$__pi_shell_bridge_cur" | sort -u
49
+ exit 0
50
+ fi
51
+
52
+ __pi_shell_bridge_cmd=${COMP_WORDS[0]}
53
+ __pi_shell_bridge_spec=$(complete -p -- "$__pi_shell_bridge_cmd" 2>/dev/null)
54
+ if [[ -z $__pi_shell_bridge_spec ]]; then
55
+ if declare -F _comp_load >/dev/null; then
56
+ _comp_load -- "$__pi_shell_bridge_cmd" >/dev/null 2>&1
57
+ elif declare -F _completion_loader >/dev/null; then
58
+ _completion_loader "$__pi_shell_bridge_cmd" >/dev/null 2>&1
59
+ fi
60
+ __pi_shell_bridge_spec=$(complete -p -- "$__pi_shell_bridge_cmd" 2>/dev/null)
61
+ fi
62
+
63
+ if [[ $__pi_shell_bridge_spec =~ [[:space:]]-F[[:space:]]+([^[:space:]]+) ]]; then
64
+ COMPREPLY=()
65
+ "${BASH_REMATCH[1]}" "$__pi_shell_bridge_cmd" "$__pi_shell_bridge_cur" "${COMP_WORDS[COMP_CWORD - 1]}" >/dev/null 2>&1
66
+ if ((${#COMPREPLY[@]} > 0)); then
67
+ printf '%s\n' "${COMPREPLY[@]}" | __pi_shell_bridge_print
68
+ exit 0
69
+ fi
70
+ elif [[ -n $__pi_shell_bridge_spec ]]; then
71
+ __pi_shell_bridge_opts=${__pi_shell_bridge_spec#complete }
72
+ __pi_shell_bridge_opts=${__pi_shell_bridge_opts% *}
73
+ eval "compgen $__pi_shell_bridge_opts -- \"\$__pi_shell_bridge_cur\"" 2>/dev/null | __pi_shell_bridge_print
74
+ exit 0
75
+ fi
76
+
77
+ compgen -f -- "$__pi_shell_bridge_cur" | __pi_shell_bridge_print
@@ -0,0 +1,92 @@
1
+ import type { AutocompleteItem, AutocompleteProvider } from "@earendil-works/pi-tui";
2
+ import type { ShellBridgeConfig, ShellName } from "./config.ts";
3
+ import { completeWith } from "./shells.ts";
4
+
5
+ const MAX_ITEMS = 200;
6
+
7
+ /** Text after `!` / `!!` and before the cursor, or undefined outside a bash-mode line. */
8
+ export function shellCommandBeforeCursor(lines: string[], cursorLine: number, cursorCol: number): string | undefined {
9
+ if (cursorLine !== 0) return undefined;
10
+ const match = (lines[0] ?? "").slice(0, cursorCol).match(/^\s*!!?(.*)$/s);
11
+ return match?.[1]?.trimStart();
12
+ }
13
+
14
+ /** The word being completed: everything after the last unescaped, unquoted whitespace. */
15
+ export function currentWord(command: string): string {
16
+ let start = 0;
17
+ let quote: string | undefined;
18
+ for (let index = 0; index < command.length; index++) {
19
+ const char = command[index];
20
+ if (char === "\\" && quote !== "'") {
21
+ index++;
22
+ } else if (quote) {
23
+ if (char === quote) quote = undefined;
24
+ } else if (char === "'" || char === '"') {
25
+ quote = char;
26
+ } else if (/\s/.test(char)) {
27
+ start = index + 1;
28
+ }
29
+ }
30
+ return command.slice(start);
31
+ }
32
+
33
+ export function applyShellCompletion(
34
+ lines: string[],
35
+ cursorLine: number,
36
+ cursorCol: number,
37
+ value: string,
38
+ prefix: string,
39
+ ): { lines: string[]; cursorLine: number; cursorCol: number } {
40
+ const line = lines[cursorLine] ?? "";
41
+ const before = line.slice(0, cursorCol - prefix.length);
42
+ const after = line.slice(cursorCol);
43
+ // Keep completing inside directories and `--option=` values; otherwise finish the word.
44
+ const suffix = /[/=:]$/.test(value) || /^\s/.test(after) ? "" : " ";
45
+ const next = [...lines];
46
+ next[cursorLine] = before + value + suffix + after;
47
+ return { lines: next, cursorLine, cursorCol: before.length + value.length + suffix.length };
48
+ }
49
+
50
+ export function createShellCompletionProvider(
51
+ current: AutocompleteProvider,
52
+ shell: ShellName,
53
+ getCwd: () => string,
54
+ config: ShellBridgeConfig,
55
+ ): AutocompleteProvider {
56
+ return {
57
+ triggerCharacters: current.triggerCharacters,
58
+
59
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
60
+ const command = shellCommandBeforeCursor(lines, cursorLine, cursorCol);
61
+ // Spawn a shell only on an explicit Tab (force), never on every keystroke.
62
+ if (command === undefined || !options.force) {
63
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
64
+ }
65
+ let completions: Awaited<ReturnType<typeof completeWith>>;
66
+ try {
67
+ completions = await completeWith(shell, command, getCwd(), config, options.signal);
68
+ } catch {
69
+ return null;
70
+ }
71
+ if (options.signal.aborted || completions.length === 0) return null;
72
+ const items: AutocompleteItem[] = completions.slice(0, MAX_ITEMS).map((completion) => ({
73
+ value: completion.value,
74
+ label: completion.value,
75
+ description: completion.description,
76
+ }));
77
+ return { items, prefix: currentWord(command) };
78
+ },
79
+
80
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
81
+ if (shellCommandBeforeCursor(lines, cursorLine, cursorCol) === undefined) {
82
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
83
+ }
84
+ return applyShellCompletion(lines, cursorLine, cursorCol, item.value, prefix);
85
+ },
86
+
87
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
88
+ if (shellCommandBeforeCursor(lines, cursorLine, cursorCol) !== undefined) return true;
89
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
90
+ },
91
+ };
92
+ }
package/src/config.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export const SHELLS = ["fish", "zsh", "bash"] as const;
6
+ export type ShellName = (typeof SHELLS)[number];
7
+
8
+ export interface ShellBridgeConfig {
9
+ /** Completion backend for `!` lines: a shell name, "auto" ($SHELL first) or "off". */
10
+ completion: ShellName | "auto" | "off";
11
+ /** Shells whose history receives `!` commands: "auto" (shells with an existing history file), "off" or a list. */
12
+ history: ShellName[] | "auto" | "off";
13
+ /** Fish-style inline suggestions from the launching shell's history on `!` lines. */
14
+ autosuggest: boolean;
15
+ /** Load the user's shell startup files (aliases, custom completions, HISTFILE). */
16
+ loadUserConfig: boolean;
17
+ /** Regular expressions; matching commands are not written to history. */
18
+ ignore: string[];
19
+ }
20
+
21
+ export const DEFAULT_CONFIG: ShellBridgeConfig = {
22
+ completion: "auto",
23
+ history: "auto",
24
+ autosuggest: true,
25
+ loadUserConfig: true,
26
+ ignore: [],
27
+ };
28
+
29
+ export function configPath(): string {
30
+ return join(getAgentDir(), "pi-shell-bridge.json");
31
+ }
32
+
33
+ function isShell(value: unknown): value is ShellName {
34
+ return typeof value === "string" && (SHELLS as readonly string[]).includes(value);
35
+ }
36
+
37
+ function parseCompletion(value: unknown): ShellBridgeConfig["completion"] | undefined {
38
+ if (typeof value !== "string") return undefined;
39
+ const normalized = value.trim().toLowerCase();
40
+ return normalized === "auto" || normalized === "off" || isShell(normalized) ? normalized : undefined;
41
+ }
42
+
43
+ function parseHistory(value: unknown): ShellBridgeConfig["history"] | undefined {
44
+ if (typeof value === "string") {
45
+ const normalized = value.trim().toLowerCase();
46
+ if (normalized === "auto" || normalized === "off") return normalized;
47
+ value = normalized.split(",");
48
+ }
49
+ if (!Array.isArray(value)) return undefined;
50
+ const shells = value.map((item) => (typeof item === "string" ? item.trim().toLowerCase() : item)).filter(isShell);
51
+ return [...new Set(shells)];
52
+ }
53
+
54
+ function parseBoolean(value: unknown): boolean | undefined {
55
+ if (typeof value === "boolean") return value;
56
+ if (typeof value !== "string") return undefined;
57
+ const normalized = value.trim().toLowerCase();
58
+ if (["1", "true", "on", "yes"].includes(normalized)) return true;
59
+ if (["0", "false", "off", "no"].includes(normalized)) return false;
60
+ return undefined;
61
+ }
62
+
63
+ export function parseConfig(raw: unknown, env: NodeJS.ProcessEnv = process.env): ShellBridgeConfig {
64
+ const object = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
65
+ return {
66
+ completion:
67
+ parseCompletion(env.PI_SHELL_BRIDGE_COMPLETION) ?? parseCompletion(object.completion) ?? DEFAULT_CONFIG.completion,
68
+ history: parseHistory(env.PI_SHELL_BRIDGE_HISTORY) ?? parseHistory(object.history) ?? DEFAULT_CONFIG.history,
69
+ autosuggest:
70
+ parseBoolean(env.PI_SHELL_BRIDGE_AUTOSUGGEST) ?? parseBoolean(object.autosuggest) ?? DEFAULT_CONFIG.autosuggest,
71
+ loadUserConfig:
72
+ typeof object.loadUserConfig === "boolean" ? object.loadUserConfig : DEFAULT_CONFIG.loadUserConfig,
73
+ ignore: Array.isArray(object.ignore) ? object.ignore.filter((item): item is string => typeof item === "string") : [],
74
+ };
75
+ }
76
+
77
+ export function loadConfig(): { config: ShellBridgeConfig; error?: string } {
78
+ let raw: unknown;
79
+ try {
80
+ raw = JSON.parse(readFileSync(configPath(), "utf8"));
81
+ } catch (error) {
82
+ const code = (error as NodeJS.ErrnoException).code;
83
+ const message = code === "ENOENT" ? undefined : `${configPath()}: ${(error as Error).message}`;
84
+ return { config: parseConfig(undefined), error: message };
85
+ }
86
+ return { config: parseConfig(raw) };
87
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export interface RunOptions {
4
+ cwd?: string;
5
+ timeout?: number;
6
+ signal?: AbortSignal;
7
+ }
8
+
9
+ export interface RunResult {
10
+ stdout: string;
11
+ stderr: string;
12
+ code: number;
13
+ killed: boolean;
14
+ }
15
+
16
+ /**
17
+ * Run a helper shell in its own session (setsid) with no controlling terminal.
18
+ *
19
+ * Interactive shells (`bash -i`, `zsh -i`) enable job control and move themselves into the terminal's
20
+ * foreground process group. Started with Pi's controlling terminal, they push Pi into the background and
21
+ * Pi is stopped by SIGTTIN/SIGTTOU ("fish: Job 1, 'pi' has stopped"). A new session has no terminal to take.
22
+ */
23
+ export function run(command: string, args: string[], options: RunOptions = {}): Promise<RunResult> {
24
+ return new Promise((resolve) => {
25
+ let stdout = "";
26
+ let stderr = "";
27
+ let killed = false;
28
+ let settled = false;
29
+ let timer: NodeJS.Timeout | undefined;
30
+
31
+ let child: ReturnType<typeof spawn>;
32
+ try {
33
+ child = spawn(command, args, {
34
+ cwd: options.cwd,
35
+ detached: true,
36
+ stdio: ["ignore", "pipe", "pipe"],
37
+ });
38
+ } catch (error) {
39
+ resolve({ stdout, stderr: (error as Error).message, code: 1, killed });
40
+ return;
41
+ }
42
+
43
+ const kill = () => {
44
+ if (killed || child.pid === undefined) return;
45
+ killed = true;
46
+ try {
47
+ process.kill(-child.pid, "SIGKILL"); // whole process group, including zpty children
48
+ } catch {
49
+ child.kill("SIGKILL");
50
+ }
51
+ };
52
+ const finish = (code: number) => {
53
+ if (settled) return;
54
+ settled = true;
55
+ if (timer) clearTimeout(timer);
56
+ options.signal?.removeEventListener("abort", kill);
57
+ resolve({ stdout, stderr, code, killed });
58
+ };
59
+
60
+ if (options.signal?.aborted) kill();
61
+ else options.signal?.addEventListener("abort", kill, { once: true });
62
+ if (options.timeout && options.timeout > 0) timer = setTimeout(kill, options.timeout);
63
+
64
+ child.stdout?.on("data", (data: Buffer) => (stdout += data.toString()));
65
+ child.stderr?.on("data", (data: Buffer) => (stderr += data.toString()));
66
+ child.on("error", (error) => {
67
+ stderr += error.message;
68
+ finish(1);
69
+ });
70
+ child.on("close", (code) => finish(code ?? 1));
71
+ });
72
+ }
package/src/history.ts ADDED
@@ -0,0 +1,161 @@
1
+ import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
2
+ import type { ShellName } from "./config.ts";
3
+
4
+ /** Read at most this much from the end of a history file. */
5
+ const MAX_READ_BYTES = 8 * 1024 * 1024;
6
+ /** Re-check the history file's mtime at most this often. */
7
+ const RECHECK_MS = 1_000;
8
+
9
+ function readTail(file: string): Buffer {
10
+ const fd = openSync(file, "r");
11
+ try {
12
+ const size = fstatSync(fd).size;
13
+ const length = Math.min(size, MAX_READ_BYTES);
14
+ const buffer = Buffer.alloc(length);
15
+ readSync(fd, buffer, 0, length, size - length);
16
+ return buffer;
17
+ } finally {
18
+ closeSync(fd);
19
+ }
20
+ }
21
+
22
+ /** Undo zsh's metafication: 0x83 (Meta) followed by a byte XOR 0x20. */
23
+ export function unmetafy(buffer: Buffer): Buffer {
24
+ if (!buffer.includes(0x83)) return buffer;
25
+ const out = Buffer.alloc(buffer.length);
26
+ let length = 0;
27
+ for (let index = 0; index < buffer.length; index++) {
28
+ const byte = buffer[index];
29
+ if (byte === 0x83 && index + 1 < buffer.length) out[length++] = buffer[++index] ^ 0x20;
30
+ else out[length++] = byte;
31
+ }
32
+ return out.subarray(0, length);
33
+ }
34
+
35
+ /** fish_history: YAML-like `- cmd: <escaped>` entries, oldest first. */
36
+ export function parseFish(text: string): string[] {
37
+ const commands: string[] = [];
38
+ for (const line of text.split("\n")) {
39
+ if (!line.startsWith("- cmd: ")) continue;
40
+ commands.push(line.slice(7).replace(/\\(\\|n)/g, (_match, char: string) => (char === "n" ? "\n" : "\\")));
41
+ }
42
+ return commands;
43
+ }
44
+
45
+ /** zsh history (plain or EXTENDED_HISTORY), oldest first; continuation lines end with a backslash. */
46
+ export function parseZsh(text: string): string[] {
47
+ const commands: string[] = [];
48
+ let pending: string | undefined;
49
+ for (const raw of text.split("\n")) {
50
+ let line = raw;
51
+ if (pending === undefined) {
52
+ const extended = line.match(/^: \d+:\d+;(.*)$/s);
53
+ if (extended) line = extended[1];
54
+ pending = "";
55
+ }
56
+ if (line.endsWith("\\")) {
57
+ pending += `${line.slice(0, -1)}\n`;
58
+ continue;
59
+ }
60
+ pending += line;
61
+ if (pending) commands.push(pending);
62
+ pending = undefined;
63
+ }
64
+ if (pending) commands.push(pending.replace(/\n$/, ""));
65
+ return commands;
66
+ }
67
+
68
+ /** bash history, oldest first; `#<epoch>` lines are HISTTIMEFORMAT timestamps. */
69
+ export function parseBash(text: string): string[] {
70
+ return text.split("\n").filter((line) => line.length > 0 && !/^#\d{9,}$/.test(line));
71
+ }
72
+
73
+ export function parseHistory(shell: ShellName, buffer: Buffer): string[] {
74
+ switch (shell) {
75
+ case "fish":
76
+ return parseFish(buffer.toString("utf8"));
77
+ case "zsh":
78
+ return parseZsh(unmetafy(buffer).toString("utf8"));
79
+ case "bash":
80
+ return parseBash(buffer.toString("utf8"));
81
+ }
82
+ }
83
+
84
+ /** Only single-line commands without control characters can be drawn inline. */
85
+ const UNSUGGESTABLE = /[\u0000-\u001f\u007f]/;
86
+
87
+ /** Single-line commands, newest first, without duplicates. */
88
+ function newestFirst(commands: string[]): string[] {
89
+ const seen = new Set<string>();
90
+ const result: string[] = [];
91
+ for (let index = commands.length - 1; index >= 0; index--) {
92
+ const command = commands[index].trim();
93
+ if (!command || UNSUGGESTABLE.test(command) || seen.has(command)) continue;
94
+ seen.add(command);
95
+ result.push(command);
96
+ }
97
+ return result;
98
+ }
99
+
100
+ /**
101
+ * Commands for fish-style autosuggestions: `!` commands of this Pi session first, then the shell's history file.
102
+ * The file is re-read only when its mtime changes, so lookups stay cheap enough for every keystroke.
103
+ */
104
+ export class HistoryIndex {
105
+ private session: string[] = [];
106
+ private fromFile: string[] = [];
107
+ private shell: ShellName | undefined;
108
+ private file: string | undefined;
109
+ private mtimeMs = -1;
110
+ private checkedAt = 0;
111
+ private cache: { prefix: string; version: number; suffix: string | undefined } | undefined;
112
+ private version = 0;
113
+
114
+ setSource(shell: ShellName | undefined, file: string | undefined): void {
115
+ this.shell = shell;
116
+ this.file = file;
117
+ this.mtimeMs = -1;
118
+ this.checkedAt = 0;
119
+ this.refresh();
120
+ }
121
+
122
+ /** Record a `!` command run in this session (shown before the file catches up). */
123
+ remember(command: string): void {
124
+ const trimmed = command.trim();
125
+ if (!trimmed || UNSUGGESTABLE.test(trimmed)) return;
126
+ this.session = [trimmed, ...this.session.filter((item) => item !== trimmed)];
127
+ this.version++;
128
+ }
129
+
130
+ private refresh(): void {
131
+ const now = Date.now();
132
+ if (!this.shell || !this.file || now - this.checkedAt < RECHECK_MS) return;
133
+ this.checkedAt = now;
134
+ try {
135
+ const mtimeMs = statSync(this.file).mtimeMs;
136
+ if (mtimeMs === this.mtimeMs) return;
137
+ this.mtimeMs = mtimeMs;
138
+ this.fromFile = newestFirst(parseHistory(this.shell, readTail(this.file)));
139
+ } catch {
140
+ this.fromFile = [];
141
+ }
142
+ this.version++;
143
+ }
144
+
145
+ /** The text that completes `prefix` to the newest matching command, or undefined. */
146
+ suggest(prefix: string): string | undefined {
147
+ if (!prefix.trim()) return undefined;
148
+ this.refresh();
149
+ if (this.cache?.prefix === prefix && this.cache.version === this.version) return this.cache.suffix;
150
+ let suffix: string | undefined;
151
+ for (const list of [this.session, this.fromFile]) {
152
+ const match = list.find((command) => command.length > prefix.length && command.startsWith(prefix));
153
+ if (match) {
154
+ suffix = match.slice(prefix.length);
155
+ break;
156
+ }
157
+ }
158
+ this.cache = { prefix, version: this.version, suffix };
159
+ return suffix;
160
+ }
161
+ }
package/src/index.ts ADDED
@@ -0,0 +1,95 @@
1
+ // pi-shell-bridge: shell completion and shell history for Pi's `!` / `!!` commands.
2
+ //
3
+ // - Tab on a `!cmd` line asks fish, zsh or bash for completions, reusing the user's shell setup.
4
+ // - Each `!` command is appended to the fish / zsh / bash history files the user keeps.
5
+ // - Fish-style autosuggestions: the newest matching history command is shown dimmed after the cursor.
6
+ // Commands still run through Pi's built-in bash; this extension does not change execution.
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { autosuggestEditorFactory } from "./autosuggest.ts";
10
+ import { createShellCompletionProvider } from "./completion.ts";
11
+ import { loadConfig, type ShellName } from "./config.ts";
12
+ import { HistoryIndex } from "./history.ts";
13
+ import {
14
+ appendHistory,
15
+ historyFile,
16
+ resolveCompletionShell,
17
+ resolveHistoryShells,
18
+ suggestionShell,
19
+ } from "./shells.ts";
20
+
21
+ export default function piShellBridge(pi: ExtensionAPI): void {
22
+ const { config, error } = loadConfig();
23
+ const ignore = config.ignore.flatMap((pattern) => {
24
+ try {
25
+ return [new RegExp(pattern)];
26
+ } catch {
27
+ return [];
28
+ }
29
+ });
30
+ let historyShells: Promise<ShellName[]> | undefined;
31
+ const suggestions = new HistoryIndex();
32
+ let suggestionSource: Promise<void> | undefined;
33
+ let editorFactory: ReturnType<typeof autosuggestEditorFactory> | undefined;
34
+ let cwd = process.cwd();
35
+
36
+ const warn = (ctx: { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } }, error: unknown) => {
37
+ try {
38
+ ctx.ui.notify(`pi-shell-bridge: ${error instanceof Error ? error.message : String(error)}`, "warning");
39
+ } catch {
40
+ // The UI may already be gone; never let the bridge take Pi down.
41
+ }
42
+ };
43
+ const shellsForHistory = () =>
44
+ (historyShells ??= resolveHistoryShells(config).catch((): ShellName[] => []));
45
+
46
+ pi.on("session_start", async (_event, ctx) => {
47
+ try {
48
+ cwd = ctx.cwd;
49
+ if (error) warn(ctx, error);
50
+ const shell = resolveCompletionShell(config);
51
+ if (shell) {
52
+ ctx.ui.addAutocompleteProvider((current) =>
53
+ createShellCompletionProvider(current, shell, () => cwd, config),
54
+ );
55
+ }
56
+ // Probe history locations in the background so the first command is not delayed.
57
+ void shellsForHistory();
58
+ if (config.autosuggest) {
59
+ const current = ctx.ui.getEditorComponent();
60
+ // Another extension owns the editor: leave it alone rather than replacing it.
61
+ if (current === undefined || current === editorFactory) {
62
+ editorFactory ??= autosuggestEditorFactory(suggestions, (text) => ctx.ui.theme.fg("dim", text));
63
+ if (current === undefined) ctx.ui.setEditorComponent(editorFactory);
64
+ const shell = suggestionShell(config);
65
+ suggestionSource ??= (shell ? historyFile(shell, config) : Promise.resolve(undefined))
66
+ .then((file) => suggestions.setSource(shell, file))
67
+ .catch(() => undefined);
68
+ }
69
+ }
70
+ } catch (caught) {
71
+ warn(ctx, caught);
72
+ }
73
+ });
74
+
75
+ pi.on("user_bash", async (event, ctx) => {
76
+ try {
77
+ cwd = event.cwd || cwd;
78
+ const command = event.command.trim();
79
+ if (!command || ignore.some((pattern) => pattern.test(command))) return undefined;
80
+ suggestions.remember(command);
81
+ void shellsForHistory()
82
+ .then((shells) => Promise.allSettled(shells.map((shell) => appendHistory(shell, command, config))))
83
+ .then((results) => {
84
+ const failures = results
85
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
86
+ .map((result) => (result.reason instanceof Error ? result.reason.message : String(result.reason)));
87
+ if (failures.length > 0) warn(ctx, failures.join("; "));
88
+ })
89
+ .catch((caught: unknown) => warn(ctx, caught));
90
+ } catch (caught) {
91
+ warn(ctx, caught);
92
+ }
93
+ return undefined; // let Pi run the command as usual
94
+ });
95
+ }
package/src/shells.ts ADDED
@@ -0,0 +1,340 @@
1
+ import { accessSync, closeSync, constants, existsSync, fstatSync, openSync, readSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, delimiter, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { execFileSync } from "node:child_process";
6
+ import { SHELLS, type ShellBridgeConfig, type ShellName } from "./config.ts";
7
+ import { type RunResult, run } from "./exec.ts";
8
+
9
+ const MARKER = "__PI_SHELL_BRIDGE_READY__";
10
+ const COMPLETE_TIMEOUT_MS = 5_000;
11
+ const HISTORY_TIMEOUT_MS = 5_000;
12
+ const PROBE_TIMEOUT_MS = 5_000;
13
+
14
+ const BASH_COMPLETE_SCRIPT = fileURLToPath(new URL("./bash-complete.bash", import.meta.url));
15
+ const ZSH_CAPTURE_SCRIPT = fileURLToPath(new URL("./zsh-capture.zsh", import.meta.url));
16
+
17
+ export interface Completion {
18
+ value: string;
19
+ description?: string;
20
+ }
21
+
22
+ interface HistoryTarget {
23
+ file: string;
24
+ /** bash: HISTTIMEFORMAT value (undefined when unset); zsh: "1" when EXTENDED_HISTORY is set. */
25
+ format?: string;
26
+ }
27
+
28
+ const binaryCache = new Map<ShellName, boolean>();
29
+
30
+ export function isInstalled(shell: ShellName): boolean {
31
+ const cached = binaryCache.get(shell);
32
+ if (cached !== undefined) return cached;
33
+ const found = (process.env.PATH ?? "").split(delimiter).some((dir) => {
34
+ if (!dir) return false;
35
+ try {
36
+ accessSync(join(dir, shell), constants.X_OK);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ });
42
+ binaryCache.set(shell, found);
43
+ return found;
44
+ }
45
+
46
+ function asShell(name: string | undefined): ShellName | undefined {
47
+ const normalized = basename(name ?? "").replace(/^-/, ""); // login shells show up as "-zsh"
48
+ return (SHELLS as readonly string[]).includes(normalized) ? (normalized as ShellName) : undefined;
49
+ }
50
+
51
+ const MAX_ANCESTORS = 16;
52
+ /** Interactive shells we recognise as "the shell Pi was launched from", supported or not. */
53
+ const KNOWN_SHELLS = new Set([
54
+ ...SHELLS,
55
+ "sh", "dash", "ash", "ksh", "mksh", "oksh", "pdksh", "csh", "tcsh", "nu", "xonsh", "elvish",
56
+ "pwsh", "powershell", "ion", "oil", "osh", "ysh", "murex", "rc", "es", "yash", "posh",
57
+ ]);
58
+
59
+ export interface LaunchingShell {
60
+ /** Executable name of the nearest shell ancestor, e.g. "fish" or "nu"; undefined when none was found. */
61
+ name?: string;
62
+ /** The same shell when pi-shell-bridge supports it. */
63
+ shell?: ShellName;
64
+ }
65
+
66
+ let launchingShell: LaunchingShell | undefined;
67
+
68
+ /** The nearest shell ancestor of this Pi process, i.e. the shell Pi was started from. Never throws. */
69
+ export function detectLaunchingShell(): LaunchingShell {
70
+ if (launchingShell) return launchingShell;
71
+ launchingShell = {};
72
+ let pid = process.ppid;
73
+ try {
74
+ for (let depth = 0; depth < MAX_ANCESTORS && pid > 1; depth++) {
75
+ const output = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], {
76
+ encoding: "utf8",
77
+ stdio: ["ignore", "pipe", "ignore"],
78
+ timeout: 1_000,
79
+ }).trim();
80
+ const match = output.match(/^(\d+)\s+(.+)$/);
81
+ if (!match) break;
82
+ const name = basename(match[2].trim()).replace(/^-/, "");
83
+ if (KNOWN_SHELLS.has(name)) {
84
+ launchingShell = { name, shell: asShell(name) };
85
+ break;
86
+ }
87
+ pid = Number(match[1]);
88
+ }
89
+ } catch {
90
+ // `ps` missing or failing: behave as if no launching shell was found.
91
+ }
92
+ return launchingShell;
93
+ }
94
+
95
+ /**
96
+ * The shell `auto` refers to: the shell Pi was launched from. Only when no shell ancestor is found
97
+ * (e.g. Pi started directly by a terminal or IDE) does $SHELL stand in. An unsupported launching shell
98
+ * (nu, sh, …) yields undefined.
99
+ */
100
+ export function autoShell(): ShellName | undefined {
101
+ const launching = detectLaunchingShell();
102
+ const shell = launching.name ? launching.shell : asShell(process.env.SHELL);
103
+ return shell && isInstalled(shell) ? shell : undefined;
104
+ }
105
+
106
+ export function resolveCompletionShell(config: ShellBridgeConfig): ShellName | undefined {
107
+ if (config.completion === "off") return undefined;
108
+ if (config.completion !== "auto") return isInstalled(config.completion) ? config.completion : undefined;
109
+ // Completion only runs on Tab and never touches history, so any installed shell is a fine fallback.
110
+ const login = asShell(process.env.SHELL);
111
+ return autoShell() ?? (login && isInstalled(login) ? login : undefined) ?? SHELLS.find(isInstalled);
112
+ }
113
+
114
+ function afterMarker(stdout: string): string[] {
115
+ const index = stdout.lastIndexOf(`${MARKER}\n`);
116
+ const body = index === -1 ? "" : stdout.slice(index + MARKER.length + 1);
117
+ return body.split("\n").map((line) => line.replace(/\r$/, ""));
118
+ }
119
+
120
+ export function parseCompletions(lines: string[]): Completion[] {
121
+ const byValue = new Map<string, Completion>();
122
+ for (const line of lines) {
123
+ if (!line.trim()) continue;
124
+ const tab = line.indexOf("\t");
125
+ const value = tab === -1 ? line : line.slice(0, tab);
126
+ const description = tab === -1 ? undefined : line.slice(tab + 1).trim() || undefined;
127
+ const existing = byValue.get(value);
128
+ if (!existing) byValue.set(value, { value, description });
129
+ else if (!existing.description && description) existing.description = description;
130
+ }
131
+ return [...byValue.values()];
132
+ }
133
+
134
+ /** Ask a shell to complete `commandLine` (text before the cursor) in `cwd`. */
135
+ export async function completeWith(
136
+ shell: ShellName,
137
+ commandLine: string,
138
+ cwd: string,
139
+ config: ShellBridgeConfig,
140
+ signal: AbortSignal,
141
+ ): Promise<Completion[]> {
142
+ const options = { cwd, signal, timeout: COMPLETE_TIMEOUT_MS };
143
+ let result: RunResult;
144
+ let lines: string[];
145
+ switch (shell) {
146
+ case "fish": {
147
+ const args = config.loadUserConfig ? [] : ["--no-config"];
148
+ result = await run(
149
+ "fish",
150
+ [...args, "-c", `echo ${MARKER}; complete -C -- "$argv[1]"`, commandLine],
151
+ options,
152
+ );
153
+ lines = afterMarker(result.stdout);
154
+ break;
155
+ }
156
+ case "bash": {
157
+ const args = config.loadUserConfig ? ["-i"] : ["--norc", "--noprofile"];
158
+ result = await run("bash", [...args, BASH_COMPLETE_SCRIPT, commandLine, MARKER], options);
159
+ lines = afterMarker(result.stdout);
160
+ break;
161
+ }
162
+ case "zsh": {
163
+ const args = [ZSH_CAPTURE_SCRIPT, commandLine];
164
+ if (!config.loadUserConfig) args.push("no-rc");
165
+ result = await run("zsh", ["-f", ...args], options);
166
+ lines = result.stdout.split("\n");
167
+ break;
168
+ }
169
+ }
170
+ if (result.killed) return [];
171
+ return parseCompletions(lines);
172
+ }
173
+
174
+ const targetCache = new Map<ShellName, Promise<HistoryTarget | undefined>>();
175
+
176
+ function fileExists(file: string): boolean {
177
+ try {
178
+ return statSync(file).isFile();
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /** Detect EXTENDED_HISTORY (": <epoch>:<duration>;command") from the tail of an existing zsh history file. */
185
+ function sniffZshExtended(file: string): boolean {
186
+ if (!fileExists(file)) return false;
187
+ try {
188
+ const fd = openSync(file, "r");
189
+ try {
190
+ const size = fstatSync(fd).size;
191
+ const length = Math.min(size, 4096);
192
+ const buffer = Buffer.alloc(length);
193
+ readSync(fd, buffer, 0, length, size - length);
194
+ return /^: \d+:\d+;/m.test(buffer.toString("latin1"));
195
+ } finally {
196
+ closeSync(fd);
197
+ }
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ async function probeHistoryTarget(
204
+ shell: ShellName,
205
+ config: ShellBridgeConfig,
206
+ ): Promise<HistoryTarget | undefined> {
207
+ const home = homedir();
208
+ switch (shell) {
209
+ case "fish": {
210
+ // fish owns its history file format and location (fish_history); `history append` handles both.
211
+ const dataHome = process.env.XDG_DATA_HOME || join(home, ".local", "share");
212
+ return { file: join(dataHome, "fish", "fish_history") };
213
+ }
214
+ case "bash": {
215
+ let file = process.env.HISTFILE || join(home, ".bash_history");
216
+ let format: string | undefined = process.env.HISTTIMEFORMAT;
217
+ if (config.loadUserConfig) {
218
+ const result = await run(
219
+ "bash",
220
+ ["-i", "-c", `printf '%s\\n' ${MARKER} "$HISTFILE" "\${HISTTIMEFORMAT-__unset__}"`],
221
+ { cwd: home, timeout: PROBE_TIMEOUT_MS },
222
+ );
223
+ const [histfile, timeFormat] = afterMarker(result.stdout);
224
+ if (histfile) file = histfile;
225
+ if (timeFormat !== undefined) format = timeFormat === "__unset__" ? undefined : timeFormat;
226
+ }
227
+ return { file, format };
228
+ }
229
+ case "zsh": {
230
+ // zsh has no default HISTFILE: without one the user does not keep history.
231
+ let file = process.env.HISTFILE;
232
+ let format: string | undefined;
233
+ if (config.loadUserConfig) {
234
+ const result = await run(
235
+ "zsh",
236
+ ["-i", "-c", `print -rl -- ${MARKER} "$HISTFILE"; [[ -o extendedhistory ]] && print 1 || print 0`],
237
+ { cwd: home, timeout: PROBE_TIMEOUT_MS },
238
+ );
239
+ const [histfile, extended] = afterMarker(result.stdout);
240
+ if (histfile) file = histfile;
241
+ format = extended;
242
+ }
243
+ if (!file) return undefined;
244
+ format ??= sniffZshExtended(file) ? "1" : "0";
245
+ return { file, format };
246
+ }
247
+ }
248
+ }
249
+
250
+ function historyTarget(shell: ShellName, config: ShellBridgeConfig): Promise<HistoryTarget | undefined> {
251
+ let target = targetCache.get(shell);
252
+ if (!target) {
253
+ target = probeHistoryTarget(shell, config).catch(() => undefined);
254
+ targetCache.set(shell, target);
255
+ }
256
+ return target;
257
+ }
258
+
259
+ /** The history file of `shell` (probed once per Pi process), or undefined when it keeps none. */
260
+ export async function historyFile(shell: ShellName, config: ShellBridgeConfig): Promise<string | undefined> {
261
+ return (await historyTarget(shell, config))?.file;
262
+ }
263
+
264
+ /** The shell whose history feeds autosuggestions: the launching shell, else the first configured history shell. */
265
+ export function suggestionShell(config: ShellBridgeConfig): ShellName | undefined {
266
+ return autoShell() ?? (Array.isArray(config.history) ? config.history.find(isInstalled) : undefined);
267
+ }
268
+
269
+ /** Resolve which shells receive history; probes run once per Pi process. */
270
+ export async function resolveHistoryShells(config: ShellBridgeConfig): Promise<ShellName[]> {
271
+ if (config.history === "off") return [];
272
+ const auto = autoShell();
273
+ const candidates = config.history === "auto" ? (auto ? [auto] : []) : config.history.filter(isInstalled);
274
+ const resolved = await Promise.all(
275
+ candidates.map(async (shell) => {
276
+ const target = await historyTarget(shell, config);
277
+ if (!target) return undefined;
278
+ // In auto mode only write to the launching shell, and only when the user keeps history for it.
279
+ return config.history !== "auto" || existsSync(target.file) ? shell : undefined;
280
+ }),
281
+ );
282
+ return resolved.filter((shell): shell is ShellName => shell !== undefined);
283
+ }
284
+
285
+ /** Append one command to a shell's history using that shell's own history writer. */
286
+ export async function appendHistory(
287
+ shell: ShellName,
288
+ command: string,
289
+ config: ShellBridgeConfig,
290
+ ): Promise<void> {
291
+ const target = await historyTarget(shell, config);
292
+ if (!target) return;
293
+ const options = { timeout: HISTORY_TIMEOUT_MS };
294
+ let result: RunResult;
295
+ switch (shell) {
296
+ case "fish": {
297
+ // --no-config disables history persistence, so fish must load its normal configuration.
298
+ result = await run("fish", ["-c", 'history append -- "$argv[1]"', command], options);
299
+ break;
300
+ }
301
+ case "bash": {
302
+ result = await run(
303
+ "bash",
304
+ [
305
+ "--norc",
306
+ "--noprofile",
307
+ "-c",
308
+ 'HISTFILE=$1; if [ "$2" != __unset__ ]; then HISTTIMEFORMAT=$2; fi; set -o history; history -s -- "$3"; history -a',
309
+ "pi-shell-bridge",
310
+ target.file,
311
+ target.format ?? "__unset__",
312
+ command,
313
+ ],
314
+ options,
315
+ );
316
+ break;
317
+ }
318
+ case "zsh": {
319
+ // Interactive (-i) so history is active; HISTFILE is unset afterwards so exit does not rewrite the file.
320
+ result = await run(
321
+ "zsh",
322
+ [
323
+ "-f",
324
+ "-i",
325
+ "-c",
326
+ '[[ $2 == 1 ]] && setopt extended_history; HISTFILE=$1; HISTSIZE=100; SAVEHIST=1000000; print -s -- "$3"; fc -AI; unset HISTFILE',
327
+ "pi-shell-bridge",
328
+ target.file,
329
+ target.format ?? "0",
330
+ command,
331
+ ],
332
+ options,
333
+ );
334
+ break;
335
+ }
336
+ }
337
+ if (result.code !== 0 || result.killed) {
338
+ throw new Error(`${shell}: ${result.stderr.trim() || (result.killed ? "timed out" : `exit ${result.code}`)}`);
339
+ }
340
+ }
@@ -0,0 +1,95 @@
1
+ # Print zsh completions for a command line without a terminal, one per line ("value<TAB>description").
2
+ # Usage: zsh -f zsh-capture.zsh <command-line> [no-rc]
3
+ #
4
+ # Uses the zpty technique popularised by Valodim/zsh-capture-completion (MIT): an interactive zsh runs
5
+ # in a pseudo terminal, `compadd` is wrapped to print every match, and Tab is sent after the command line.
6
+ # The interactive zsh loads the user's ~/.zshenv and ~/.zshrc through a temporary ZDOTDIR so that no
7
+ # setup command is typed into the line editor, and HISTFILE is unset so nothing reaches the history file.
8
+ zmodload zsh/zpty || exit 1
9
+
10
+ local line_to_complete=$1
11
+ local no_rc=$2
12
+ local dir=$(mktemp -d "${TMPDIR:-/tmp}/pi-shell-bridge.XXXXXX") || exit 1
13
+ trap 'rm -rf -- ${(q)dir}; zpty -d pi_shell_bridge 2>/dev/null' EXIT
14
+
15
+ local user_zdotdir=${ZDOTDIR:-$HOME}
16
+
17
+ if [[ $no_rc != no-rc ]]; then
18
+ cat >| $dir/.zshenv <<ZSHENV
19
+ ZDOTDIR=${(q)user_zdotdir}
20
+ [[ -r \$ZDOTDIR/.zshenv ]] && source \$ZDOTDIR/.zshenv
21
+ __pi_shell_bridge_user_zdotdir=\$ZDOTDIR
22
+ ZDOTDIR=${(q)dir}
23
+ ZSHENV
24
+ fi
25
+
26
+ cat >| $dir/.zshrc <<ZSHRC
27
+ unset HISTFILE
28
+ if [[ -n \$__pi_shell_bridge_user_zdotdir ]]; then
29
+ ZDOTDIR=\$__pi_shell_bridge_user_zdotdir
30
+ [[ -r \$ZDOTDIR/.zshrc ]] && source \$ZDOTDIR/.zshrc
31
+ fi
32
+ source ${(q)dir}/setup.zsh
33
+ ZSHRC
34
+
35
+ cat >| $dir/setup.zsh <<'SETUP'
36
+ unset HISTFILE
37
+ PROMPT= RPROMPT= PS2=
38
+ unsetopt zle_bracketed_paste 2>/dev/null
39
+ (( $+functions[compdef] )) || { autoload -Uz compinit && compinit -C -d "${TMPDIR:-/tmp}/pi-shell-bridge-zcompdump-$UID"; }
40
+ bindkey '^M' undefined
41
+ bindkey '^J' undefined
42
+ bindkey '^I' complete-word
43
+ __pi_shell_bridge_null() { print -rn -- $'\0\r\n' }
44
+ compprefuncs=( __pi_shell_bridge_null )
45
+ comppostfuncs=( __pi_shell_bridge_null exit )
46
+ zstyle ':completion:*' list-grouped false
47
+ zstyle ':completion:*' list-separator ''
48
+ zstyle ':completion:*' menu no
49
+ zmodload zsh/zutil
50
+ compadd() {
51
+ if [[ ${@[1,(i)(-|--)]} == *-(O|A|D)\ * ]]; then
52
+ builtin compadd "$@"
53
+ return $?
54
+ fi
55
+ typeset -a __hits __dscr __tmp
56
+ if (( $@[(I)-d] )); then
57
+ __tmp=${@[$[${@[(i)-d]}+1]]}
58
+ if [[ $__tmp == \(* ]]; then eval "__dscr=$__tmp"; else __dscr=( "${(@P)__tmp}" ); fi
59
+ fi
60
+ builtin compadd -A __hits -D __dscr "$@"
61
+ setopt localoptions norcexpandparam extendedglob
62
+ typeset -A apre hpre hsuf asuf
63
+ zparseopts -E P:=apre p:=hpre S:=asuf s:=hsuf
64
+ integer dirsuf=0
65
+ if [[ -z $hsuf && "${${@//-default-/}% -# *}" == *-[[:alnum:]]#f* ]]; then dirsuf=1; fi
66
+ [[ -n $__hits ]] || return
67
+ local dsuf dscr i __path
68
+ for i in {1..$#__hits}; do
69
+ __path=$hpre$__hits[$i]
70
+ (( dirsuf )) && [[ -d ${~__path} ]] && dsuf=/ || dsuf=
71
+ (( $#__dscr >= $i )) && dscr=$'\t'${${__dscr[$i]}##$__hits[$i] #} || dscr=
72
+ print -r -- "$IPREFIX$apre$hpre$__hits[$i]$dsuf$hsuf$asuf$dscr"
73
+ done
74
+ }
75
+ print -r -- __PI_SHELL_BRIDGE_READY__
76
+ SETUP
77
+
78
+ export ZDOTDIR=$dir
79
+ zpty pi_shell_bridge zsh -i
80
+ local line
81
+ while zpty -r pi_shell_bridge line; do
82
+ [[ $line == __PI_SHELL_BRIDGE_READY__* ]] && break
83
+ done
84
+ zpty -w -n pi_shell_bridge "$line_to_complete"$'\t'
85
+
86
+ integer seen=0
87
+ while zpty -r pi_shell_bridge line; do
88
+ if [[ $line == *$'\0\r'* ]]; then
89
+ (( seen++ )) && exit 0
90
+ continue
91
+ fi
92
+ line=${line//[$'\r\n']/}
93
+ (( seen )) && [[ -n $line ]] && print -r -- "$line"
94
+ done
95
+ exit 2