Skip to content
Open
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
2 changes: 1 addition & 1 deletion TERAX.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Single-window React app. Path alias `@/*` → `src/*`. Tabs are a tagged union (

Each module is self-contained, exports a thin barrel via `index.ts`, and owns its hooks under `lib/`.

- **terminal/** — `TerminalStack` keeps one mounted xterm per tab via `useTerminalSession` + `pty-bridge`. `osc-handlers.ts` parses OSC 7 (with Windows drive-letter normalization: `/C:/Users/foo` → `C:/Users/foo`) and OSC 133 markers. The xterm color palette is driven by the central theme engine (`modules/theme`), not a local table.
- **terminal/** — `TerminalStack` keeps one mounted xterm per tab via `useTerminalSession` + `pty-bridge`. `osc-handlers.ts` parses OSC 7 (with Windows drive-letter normalization: `/C:/Users/foo` → `C:/Users/foo`) and OSC 133 markers. The xterm color palette is driven by the central theme engine (`modules/theme`), not a local table. **Inline suggestions** (opt-in, `terminalSuggestions` pref): `commandHistory.ts` is the pure functional core (bounded most-recent-first ring with prefix matching, persisted to `terax-command-history.json`); commands are captured from `OSC 133;C;<cmd>` via the prompt tracker's `onCommand` callback. `suggestionOverlay.ts` renders the ghost as a DOM overlay positioned over the grid (never written into the xterm buffer or PTY, so a geometry miss is cosmetic, never corrupting). Accept with Right arrow or End (handled in `rendererPool` key handler). A module-level enabled flag short-circuits the per-frame render hook so the feature is zero-cost when off.
- **editor/** — CodeMirror 6 stack (`EditorStack` mirrors `TerminalStack`). `extensions.ts` configures language modes; supports vim mode and prebuilt themes (Tokyo Night, Nord, GitHub, Atom One, Aura, Copilot, Xcode, Gruvbox Dark).
- **explorer/** — file tree with Material/Catppuccin icons (`iconResolver.ts`), fuzzy search, keyboard nav, inline rename, context actions. Backslash-aware `basename`.
- **preview/** — auto-detected dev-server preview tab (status-bar pill suggests opening when a localhost URL is detected).
Expand Down
11 changes: 11 additions & 0 deletions src/modules/settings/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export type Preferences = {
showHidden: boolean;
terminalWebglEnabled: boolean;
terminalCursorBlink: boolean;
terminalSuggestions: boolean;
terminalFontFamily: string;
terminalLetterSpacing: number;
terminalFontSize: number;
Expand Down Expand Up @@ -126,6 +127,7 @@ const KEY_SHOW_HIDDEN = "showHidden";
const LEGACY_KEY_SHOW_HIDDEN_DIRS = "showHiddenDirectories";
const KEY_TERMINAL_WEBGL_ENABLED = "terminalWebglEnabled";
const KEY_TERMINAL_CURSOR_BLINK = "terminalCursorBlink";
const KEY_TERMINAL_SUGGESTIONS = "terminalSuggestions";
const KEY_TERMINAL_FONT_FAMILY = "terminalFontFamily";
const KEY_TERMINAL_LETTER_SPACING = "terminalLetterSpacing";
const KEY_TERMINAL_FONT_SIZE = "terminalFontSize";
Expand Down Expand Up @@ -184,6 +186,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
showHidden: false,
terminalWebglEnabled: true,
terminalCursorBlink: false,
terminalSuggestions: false,
terminalFontFamily: "",
terminalLetterSpacing: 0,
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
Expand Down Expand Up @@ -306,6 +309,9 @@ export async function loadPreferences(): Promise<Preferences> {
terminalCursorBlink:
get<boolean>(KEY_TERMINAL_CURSOR_BLINK) ??
DEFAULT_PREFERENCES.terminalCursorBlink,
terminalSuggestions:
get<boolean>(KEY_TERMINAL_SUGGESTIONS) ??
DEFAULT_PREFERENCES.terminalSuggestions,
terminalFontFamily:
get<string>(KEY_TERMINAL_FONT_FAMILY) ??
DEFAULT_PREFERENCES.terminalFontFamily,
Expand Down Expand Up @@ -487,6 +493,10 @@ export async function setTerminalCursorBlink(value: boolean): Promise<void> {
await writePref(KEY_TERMINAL_CURSOR_BLINK, value);
}

export async function setTerminalSuggestions(value: boolean): Promise<void> {
await writePref(KEY_TERMINAL_SUGGESTIONS, value);
}

export async function setTerminalFontFamily(value: string): Promise<void> {
await writePref(KEY_TERMINAL_FONT_FAMILY, value.trim());
}
Expand Down Expand Up @@ -591,6 +601,7 @@ export async function onPreferencesChange(
[KEY_SHOW_HIDDEN]: "showHidden",
[KEY_TERMINAL_WEBGL_ENABLED]: "terminalWebglEnabled",
[KEY_TERMINAL_CURSOR_BLINK]: "terminalCursorBlink",
[KEY_TERMINAL_SUGGESTIONS]: "terminalSuggestions",
[KEY_TERMINAL_FONT_FAMILY]: "terminalFontFamily",
[KEY_TERMINAL_LETTER_SPACING]: "terminalLetterSpacing",
[KEY_TERMINAL_FONT_SIZE]: "terminalFontSize",
Expand Down
79 changes: 79 additions & 0 deletions src/modules/terminal/lib/commandHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";

// The module instantiates a LazyStore lazily; stub the plugin so importing the
// pure core never reaches the Tauri bridge in unit tests.
vi.mock("@tauri-apps/plugin-store", () => ({
LazyStore: class {
get = vi.fn().mockResolvedValue(undefined);
set = vi.fn().mockResolvedValue(undefined);
save = vi.fn().mockResolvedValue(undefined);
},
}));

import { CommandRing } from "./commandHistory";

describe("CommandRing", () => {
it("returns null for an empty prefix", () => {
const ring = new CommandRing(10, ["git status"]);
expect(ring.suggest("")).toBeNull();
});

it("suggests the most-recent command extending the prefix", () => {
const ring = new CommandRing();
ring.add("git status");
ring.add("git commit -m wip");
expect(ring.suggest("git ")).toBe("git commit -m wip");
});

it("never suggests a command equal to the prefix", () => {
const ring = new CommandRing();
ring.add("ls");
expect(ring.suggest("ls")).toBeNull();
});

it("matches by prefix, not substring", () => {
const ring = new CommandRing();
ring.add("cargo build");
expect(ring.suggest("build")).toBeNull();
});

it("dedupes and promotes a re-run command to most-recent", () => {
const ring = new CommandRing();
ring.add("npm run dev");
ring.add("npm test");
ring.add("npm run dev");
expect(ring.suggest("npm ")).toBe("npm run dev");
expect(ring.size).toBe(2);
});

it("ignores empty and whitespace-only commands", () => {
const ring = new CommandRing();
ring.add(" ");
ring.add("");
expect(ring.size).toBe(0);
});

it("trims surrounding whitespace before storing", () => {
const ring = new CommandRing();
ring.add(" pwd ");
expect(ring.suggest("pw")).toBe("pwd");
});

it("enforces the max size, dropping the oldest", () => {
const ring = new CommandRing(2);
ring.add("a-one");
ring.add("b-two");
ring.add("c-three");
expect(ring.size).toBe(2);
expect(ring.suggest("a-")).toBeNull();
expect(ring.suggest("c-")).toBe("c-three");
});

it("hydrates oldest-first and round-trips through toArray", () => {
const ring = new CommandRing(10, ["old", "mid", "new"]);
expect(ring.toArray()).toEqual(["old", "mid", "new"]);
// "new" was added last, so it wins a shared prefix.
const tie = new CommandRing(10, ["git a", "git b"]);
expect(tie.suggest("git ")).toBe("git b");
});
});
106 changes: 106 additions & 0 deletions src/modules/terminal/lib/commandHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { LazyStore } from "@tauri-apps/plugin-store";

/**
* History-based command suggestions. The pure `CommandRing` is the functional
* core: a bounded, most-recent-first list with prefix matching. The module
* singleton below adds persistence and is the imperative shell.
*
* Suggestions are derived purely from the user's own shell history captured via
* OSC 133;C markers (see osc-handlers.ts). No network, no model, zero cost when
* the feature is disabled because nothing registers in that case.
*/

const DEFAULT_MAX = 1000;

export class CommandRing {
private items: string[] = [];

constructor(
private readonly max: number = DEFAULT_MAX,
initial: readonly string[] = [],
) {
// initial is oldest-first; replay through add() so dedup + cap apply and
// the result ends up most-recent-first.
for (const cmd of initial) this.add(cmd);
}

/** Record an executed command. Most-recent-first, deduped, bounded. */
add(command: string): void {
const cmd = command.trim();
if (!cmd) return;
const existing = this.items.indexOf(cmd);
if (existing !== -1) this.items.splice(existing, 1);
this.items.unshift(cmd);
if (this.items.length > this.max) this.items.length = this.max;
}

/**
* Most-recent command that starts with `prefix` and is strictly longer.
* Returns null for an empty prefix or when nothing extends it, so the caller
* never shows a no-op ghost equal to what is already typed.
*/
suggest(prefix: string): string | null {
if (!prefix) return null;
for (const cmd of this.items) {
if (cmd.length > prefix.length && cmd.startsWith(prefix)) return cmd;
}
return null;
}

/** Oldest-first snapshot, suitable for persistence. */
toArray(): string[] {
return this.items.slice().reverse();
}

get size(): number {
return this.items.length;
}
}

const STORE_PATH = "terax-command-history.json";
const STORE_KEY = "commands";

let ring = new CommandRing();
let hydrated = false;
let store: LazyStore | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;

function getStore(): LazyStore {
if (!store) store = new LazyStore(STORE_PATH, { defaults: {}, autoSave: false });
return store;
}

/** Load persisted history once. Safe to call from multiple mounts. */
export async function initCommandHistory(): Promise<void> {
if (hydrated) return;
hydrated = true;
try {
const saved = await getStore().get<string[]>(STORE_KEY);
if (Array.isArray(saved)) ring = new CommandRing(DEFAULT_MAX, saved);
} catch (e) {
console.warn("[terax] command history load failed:", e);
}
}

function scheduleSave(): void {
if (saveTimer) return;
saveTimer = setTimeout(() => {
saveTimer = null;
const s = getStore();
s.set(STORE_KEY, ring.toArray())
.then(() => s.save())
.catch((e) => console.warn("[terax] command history save failed:", e));
}, 1000);
}

export function recordCommand(command: string): void {
const before = ring.size;
ring.add(command);
// Re-adding an existing command reorders without changing size; persist
// either way so most-recent ordering survives a restart.
if (ring.size !== before || command.trim()) scheduleSave();
}

export function suggestCommand(prefix: string): string | null {
return ring.suggest(prefix);
}
41 changes: 41 additions & 0 deletions src/modules/terminal/lib/osc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,44 @@ describe("OSC 7 cwd handler — gated by OSC 133 in-command state", () => {
expect(onCwd).toHaveBeenCalledWith("C:/Users/me/project");
});
});

describe("OSC 133 prompt callbacks — inline suggestions", () => {
it("extracts the command text from OSC 133 C;<cmd>", () => {
const { term, handlers } = makeFakeTerm();
const onCommand = vi.fn();
registerPromptTracker(term, undefined, { onCommand });

handlers.get(133)?.("C;git commit -m wip");
expect(onCommand).toHaveBeenCalledWith("git commit -m wip");
});

it("preserves semicolons inside the command payload", () => {
const { term, handlers } = makeFakeTerm();
const onCommand = vi.fn();
registerPromptTracker(term, undefined, { onCommand });

handlers.get(133)?.("C;ls; echo done");
expect(onCommand).toHaveBeenCalledWith("ls; echo done");
});

it("does not fire onCommand for a bare C (bash PS0)", () => {
const { term, handlers } = makeFakeTerm();
const onCommand = vi.fn();
registerPromptTracker(term, undefined, { onCommand });

handlers.get(133)?.("C");
expect(onCommand).not.toHaveBeenCalled();
});

it("fires prompt lifecycle callbacks on A and B", () => {
const { term, handlers } = makeFakeTerm();
const onPromptStart = vi.fn();
const onInputReady = vi.fn();
registerPromptTracker(term, undefined, { onPromptStart, onInputReady });

handlers.get(133)?.("A");
handlers.get(133)?.("B");
expect(onPromptStart).toHaveBeenCalledTimes(1);
expect(onInputReady).toHaveBeenCalledTimes(1);
});
});
22 changes: 22 additions & 0 deletions src/modules/terminal/lib/osc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,23 @@ export type PromptTracker = {
dispose: () => void;
};

/**
* Optional observers for the prompt lifecycle, used by inline suggestions.
* `onCommand` receives the command text from `OSC 133;C;<cmd>` (emitted by the
* zsh/fish preexec hooks). `onInputReady` fires on `OSC 133;B`, the point where
* the prompt is fully drawn and the cursor sits at the start of user input.
*/
export type PromptCallbacks = {
onCommand?: (command: string) => void;
onPromptStart?: () => void;
onInputReady?: () => void;
onCommandRun?: () => void;
};

export function registerPromptTracker(
term: Terminal,
state?: ShellIntegrationState,
callbacks?: PromptCallbacks,
): PromptTracker {
let marker: IMarker | null = null;
const d = term.parser.registerOscHandler(133, (data) => {
Expand All @@ -50,13 +64,21 @@ export function registerPromptTracker(
if (state) state.inCommand = false;
marker?.dispose();
marker = term.registerMarker(0);
callbacks?.onPromptStart?.();
} else if (data.startsWith("B")) {
// OSC 133 B — command begins. From here on, treat all output as
// untrusted until we see D (command exit) or the next A (new prompt).
if (state) state.inCommand = true;
callbacks?.onInputReady?.();
} else if (data.startsWith("C")) {
// OSC 133 C — command pre-execution marker; still inside command.
if (state) state.inCommand = true;
callbacks?.onCommandRun?.();
// zsh/fish emit `C;<cmd>`; bash's PS0 emits a bare `C`.
const semi = data.indexOf(";");
if (semi !== -1 && callbacks?.onCommand) {
callbacks.onCommand(data.slice(semi + 1));
}
} else if (data.startsWith("D")) {
// OSC 133 D — command ends.
if (state) state.inCommand = false;
Expand Down
36 changes: 36 additions & 0 deletions src/modules/terminal/lib/rendererPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import { shouldCursorBlink } from "./cursorBlink";
import {
acceptSuggestion,
acceptSuggestionWord,
hasActiveSuggestion,
} from "./suggestionOverlay";
import {
terminalDeleteSequence,
terminalLineNavigationSequence,
Expand Down Expand Up @@ -235,6 +240,18 @@ function createSlot(): Slot {
if (leafId === null) return false;
const bridge = adapter?.resolveLeaf(leafId);
if (!bridge) return true;
if (hasActiveSuggestion(leafId)) {
if (isAcceptSuggestionWord(event)) {
event.preventDefault();
if (event.type === "keydown") acceptSuggestionWord(leafId);
return false;
}
if (isAcceptSuggestion(event)) {
event.preventDefault();
if (event.type === "keydown") acceptSuggestion(leafId);
return false;
}
}
const lineNavigation = terminalLineNavigationSequence(event, {
isMac: IS_MAC,
});
Expand Down Expand Up @@ -915,3 +932,22 @@ function isShiftEnter(e: KeyboardEvent): boolean {
e.key === "Enter" && e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey
);
}

// Accept an inline suggestion with Right arrow or End at the line end, the
// fish/zsh-autosuggestions convention. Only consumed when a ghost is showing.
function isAcceptSuggestion(e: KeyboardEvent): boolean {
return (
(e.key === "ArrowRight" || e.key === "End") &&
!e.altKey &&
!e.ctrlKey &&
!e.metaKey &&
!e.shiftKey
);
}

// Accept only the next word with Alt+Right or Ctrl+Right (forward-word).
function isAcceptSuggestionWord(e: KeyboardEvent): boolean {
return (
e.key === "ArrowRight" && (e.altKey || e.ctrlKey) && !e.metaKey && !e.shiftKey
);
}
Loading