@pi-unipi/input-shortcuts 0.1.1

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/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # @pi-unipi/input-shortcuts
2
+
3
+ Keyboard shortcuts for Pi's input box — stash/restore, undo/redo, clipboard operations, thinking toggle, and tab insertion. All accessible via a vim-style chord overlay triggered by `ALT+S`.
4
+
5
+ ## Features
6
+
7
+ | Chord | Action | Description |
8
+ |-------|--------|-------------|
9
+ | `ALT+S → S` | Stash/Restore | Save input text to stash register, or restore it |
10
+ | `ALT+S → U` | Undo | Pop from undo buffer (1s throttle) |
11
+ | `ALT+S → R` | Redo | Push current text forward, restore previous |
12
+ | `ALT+S → Y` | Copy | Copy input to system clipboard |
13
+ | `ALT+S → D` | Cut | Copy to clipboard, then clear input |
14
+ | `ALT+S → T` | Toggle Thinking | Cycle: off → low → medium → high → xhigh → off |
15
+ | `ALT+S → A → [0-9]` | Append Register | Append from numbered register 0-9 |
16
+ | `ALT+S → A → S` | Append Stash | Append from stash register |
17
+ | `ALT+I` | Tab Insert | Insert literal tab character into input |
18
+
19
+ ## How It Works
20
+
21
+ ### Chord Overlay
22
+
23
+ Press `ALT+S` to open a small overlay showing available actions with key hints. Press a single key within the overlay to execute the action. The overlay auto-closes after 300ms of inactivity or on `ESC`.
24
+
25
+ For the **Append** sub-chord, pressing `A` transitions to a second overlay showing numbered registers `[0-9]` and the stash register `[S]`.
26
+
27
+ ### Registers
28
+
29
+ - **Stash register**: 1 register for quick save/restore of input text
30
+ - **Numbered registers**: 10 registers (0-9) for appending stored text snippets
31
+ - **Persistence**: All registers saved to `.unipi/config/input-shortcuts.json` (per-project, atomic writes)
32
+
33
+ ### Undo/Redo
34
+
35
+ - In-memory ring buffer, max 50 snapshots per session
36
+ - **500ms debounce** on snapshot creation (prevents noise from rapid typing)
37
+ - **1s throttle** on undo (prevents rapid-fire undo)
38
+ - Redo buffer cleared on new snapshot (standard undo/redo semantics)
39
+ - Not persisted across sessions
40
+
41
+ ### Clipboard
42
+
43
+ Cross-platform clipboard detection with automatic fallback:
44
+
45
+ | Platform | Read | Write |
46
+ |----------|------|-------|
47
+ | Linux (X11) | `xclip -selection clipboard -o` | `xclip -selection clipboard` |
48
+ | Linux (alt) | `xsel --clipboard --output` | `xsel --clipboard --input` |
49
+ | macOS | `pbpaste` | `pbcopy` |
50
+ | Windows | `powershell Get-Clipboard` | `clip` / `powershell Set-Clipboard` |
51
+
52
+ Detected tool is cached after first use. Returns graceful error if no clipboard tool is available.
53
+
54
+ ### Thinking Toggle
55
+
56
+ Cycles through Pi's thinking levels in order:
57
+
58
+ ```
59
+ off → low → medium → high → xhigh → off
60
+ ```
61
+
62
+ ## Settings
63
+
64
+ Run `/unipi:stash-settings` to open a TUI overlay for customizing keybindings:
65
+
66
+ - **Chord trigger key** — default `alt+s`
67
+ - **Tab insert key** — default `alt+i`
68
+
69
+ Both cycle through available ALT key combinations, excluding known conflicts (`alt+e` = cursorWordRight).
70
+
71
+ Config persisted to `~/.unipi/config/input-shortcuts-config.json` (global).
72
+
73
+ ## Architecture
74
+
75
+ ```
76
+ input-shortcuts/
77
+ ├── index.ts # Re-exports
78
+ ├── src/
79
+ │ ├── index.ts # Extension entry — registers shortcuts + command
80
+ │ ├── types.ts # Shared types and constants
81
+ │ ├── registers.ts # RegisterStore — JSON persistence with atomic writes
82
+ │ ├── undo-redo.ts # UndoRedoBuffer — ring buffer with debounce/throttle
83
+ │ ├── clipboard.ts # Cross-platform clipboard detection + read/write
84
+ │ ├── status.ts # Status bar feedback with auto-clear
85
+ │ ├── chord-overlay.ts # ChordOverlay — TUI overlay component (root + register sub-chord)
86
+ │ └── settings-overlay.ts # SettingsOverlay — SettingsList-based config UI
87
+ ├── tests/
88
+ │ ├── clipboard.test.ts
89
+ │ ├── registers.test.ts
90
+ │ └── undo-redo.test.ts
91
+ └── package.json
92
+ ```
93
+
94
+ ### Key Patterns
95
+
96
+ - **TUI overlay**: Uses `ctx.ui.custom()` from pi-coding-agent (proven pattern from btw, compactor, footer)
97
+ - **SettingsList**: Uses `SettingsList` from pi-tui for the settings overlay
98
+ - **Atomic writes**: All file persistence uses write-to-tmp-then-rename pattern
99
+ - **Status feedback**: Every action shows a brief success/error message in the status bar via `ctx.ui.setStatus()`
100
+
101
+ ## Testing
102
+
103
+ ```bash
104
+ npm test --workspace=packages/input-shortcuts
105
+ ```
106
+
107
+ 19 tests across 3 suites:
108
+ - **clipboard** (4 tests): detection fallback, copy/paste roundtrip, graceful errors
109
+ - **RegisterStore** (8 tests): load/create, read/write stash/registers, corruption handling, atomic writes
110
+ - **UndoRedoBuffer** (7 tests): undo/redo roundtrip, debounce, throttle, max size eviction, clear
111
+
112
+ ## Dependencies
113
+
114
+ - `@pi-unipi/core` — shared constants and utilities
115
+ - `@mariozechner/pi-coding-agent` — ExtensionAPI, ExtensionContext
116
+ - `@mariozechner/pi-tui` — Key, Container, Text, SettingsList, Focusable
package/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @pi-unipi/input-shortcuts — Re-exports
3
+ */
4
+
5
+ export { default } from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@pi-unipi/input-shortcuts",
3
+ "version": "0.1.1",
4
+ "description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/input-shortcuts"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "pi-coding-agent",
17
+ "unipi",
18
+ "input-shortcuts",
19
+ "stash",
20
+ "undo",
21
+ "clipboard"
22
+ ],
23
+ "files": [
24
+ "index.ts",
25
+ "src/**/*.ts",
26
+ "README.md"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "src/index.ts"
31
+ ]
32
+ },
33
+ "scripts": {
34
+ "test": "node --experimental-strip-types --test tests/**/*.test.ts"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@pi-unipi/core": "*"
41
+ },
42
+ "peerDependencies": {
43
+ "@mariozechner/pi-coding-agent": "*",
44
+ "@mariozechner/pi-tui": "*"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^25.6.0",
48
+ "typescript": "^6.0.0"
49
+ }
50
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * TUI overlay component for ALT+S chord mode.
3
+ *
4
+ * Two states: root chord (action menu) and register sub-chord (register list).
5
+ * Uses ctx.ui.custom() pattern from btw/compactor.
6
+ *
7
+ * IMPORTANT: The overlay ONLY captures the user's action selection.
8
+ * Actions are NOT executed inside the overlay — they are deferred to the
9
+ * caller via callbacks (onStash, onUndo, etc.). The caller closes the
10
+ * overlay via done(), then executes the action outside the overlay context
11
+ * where ctx.ui.getEditorText() / setEditorText() actually work.
12
+ *
13
+ * Closes on ESC or after selecting an action. No timeout.
14
+ */
15
+
16
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
17
+ import {
18
+ Container,
19
+ Key,
20
+ matchesKey,
21
+ Text,
22
+ type Focusable,
23
+ type TUI,
24
+ type KeybindingsManager,
25
+ } from "@mariozechner/pi-tui";
26
+ import type { ChordState } from "./types.ts";
27
+ import { THINKING_CYCLE } from "./types.ts";
28
+
29
+ /** Theme-like interface matching pi-coding-agent's Theme */
30
+ interface ThemeLike {
31
+ fg(color: string, text: string): string;
32
+ bg?(color: string, text: string): string;
33
+ bold?(text: string): string;
34
+ italic?(text: string): string;
35
+ }
36
+
37
+ /** Action callbacks — actions execute OUTSIDE the overlay context */
38
+ export interface ChordCallbacks {
39
+ onStash: () => void;
40
+ onUndo: () => void;
41
+ onRedo: () => void;
42
+ onAppendRegister: (index: number) => void;
43
+ onAppendStash: () => void;
44
+ onCopy: () => void;
45
+ onCut: () => void;
46
+ onToggleThinking: () => void;
47
+ }
48
+
49
+ // ─── Action menu lines ──────────────────────────────────────────────────────
50
+
51
+ const ROOT_ACTIONS: Array<{ key: string; label: string }> = [
52
+ { key: "S", label: "Stash / Restore" },
53
+ { key: "U", label: "Undo" },
54
+ { key: "R", label: "Redo" },
55
+ { key: "A", label: "Append from register" },
56
+ { key: "Y", label: "Copy to clipboard" },
57
+ { key: "D", label: "Cut to clipboard" },
58
+ { key: "T", label: "Toggle thinking" },
59
+ ];
60
+
61
+ function buildRegisterActions(): Array<{ key: string; label: string }> {
62
+ const actions: Array<{ key: string; label: string }> = [];
63
+ for (let i = 0; i <= 9; i++) {
64
+ actions.push({ key: String(i), label: `Register ${i}` });
65
+ }
66
+ actions.push({ key: "S", label: "Stash register" });
67
+ return actions;
68
+ }
69
+
70
+ // ─── ChordOverlay Component ─────────────────────────────────────────────────
71
+
72
+ export class ChordOverlay extends Container implements Focusable {
73
+ private _focused = true;
74
+ private state: ChordState = "chord_root";
75
+ private actionLines: Text[] = [];
76
+ private tui: TUI;
77
+ private theme: ThemeLike;
78
+ private done: () => void;
79
+ private callbacks: ChordCallbacks;
80
+
81
+ get focused(): boolean {
82
+ return this._focused;
83
+ }
84
+
85
+ set focused(value: boolean) {
86
+ this._focused = value;
87
+ }
88
+
89
+ constructor(
90
+ tui: TUI,
91
+ theme: ThemeLike,
92
+ _keybindings: KeybindingsManager,
93
+ done: () => void,
94
+ callbacks: ChordCallbacks,
95
+ ) {
96
+ super();
97
+ this.tui = tui;
98
+ this.theme = theme;
99
+ this.done = done;
100
+ this.callbacks = callbacks;
101
+
102
+ this.renderRootMenu();
103
+ }
104
+
105
+ private renderRootMenu(): void {
106
+ this.state = "chord_root";
107
+ this.actionLines = ROOT_ACTIONS.map(
108
+ (a) => new Text(` ${this.theme.fg("accent", `[${a.key}]`)} ${a.label}`, 1, 0),
109
+ );
110
+ this.requestRender();
111
+ }
112
+
113
+ private renderRegisterMenu(): void {
114
+ this.state = "chord_reg";
115
+ const regActions = buildRegisterActions();
116
+ this.actionLines = regActions.map(
117
+ (a) => new Text(` ${this.theme.fg("accent", `[${a.key}]`)} ${a.label}`, 1, 0),
118
+ );
119
+ this.requestRender();
120
+ }
121
+
122
+ private requestRender(): void {
123
+ this.tui.requestRender();
124
+ }
125
+
126
+ handleInput(data: string): void {
127
+ if (matchesKey(data, Key.escape)) {
128
+ this.close();
129
+ return;
130
+ }
131
+
132
+ const key = data.toLowerCase();
133
+
134
+ if (this.state === "chord_root") {
135
+ this.handleRootKey(key);
136
+ } else if (this.state === "chord_reg") {
137
+ this.handleRegKey(key);
138
+ }
139
+ }
140
+
141
+ private handleRootKey(key: string): void {
142
+ switch (key) {
143
+ case "s":
144
+ this.closeThenExecute(() => this.callbacks.onStash());
145
+ break;
146
+ case "u":
147
+ this.closeThenExecute(() => this.callbacks.onUndo());
148
+ break;
149
+ case "r":
150
+ this.closeThenExecute(() => this.callbacks.onRedo());
151
+ break;
152
+ case "a":
153
+ this.enterRegChord();
154
+ return; // don't close — show register sub-menu
155
+ case "y":
156
+ this.closeThenExecute(() => this.callbacks.onCopy());
157
+ break;
158
+ case "d":
159
+ this.closeThenExecute(() => this.callbacks.onCut());
160
+ break;
161
+ case "t":
162
+ this.closeThenExecute(() => this.callbacks.onToggleThinking());
163
+ break;
164
+ default:
165
+ // Unknown key — silent close
166
+ this.close();
167
+ break;
168
+ }
169
+ }
170
+
171
+ private handleRegKey(key: string): void {
172
+ if (key === "s") {
173
+ this.closeThenExecute(() => this.callbacks.onAppendStash());
174
+ } else if (/^[0-9]$/.test(key)) {
175
+ const index = parseInt(key, 10);
176
+ this.closeThenExecute(() => this.callbacks.onAppendRegister(index));
177
+ } else {
178
+ // Unknown key — silent close
179
+ this.close();
180
+ }
181
+ }
182
+
183
+ private enterRegChord(): void {
184
+ this.renderRegisterMenu();
185
+ }
186
+
187
+ /**
188
+ * Close the overlay, then execute the action.
189
+ * The action runs AFTER the overlay is dismissed, so ctx.ui.getEditorText()
190
+ * and setEditorText() work correctly (they don't work while overlay is open).
191
+ */
192
+ private closeThenExecute(action: () => void): void {
193
+ this.done(); // close the overlay
194
+ // Use setTimeout(0) to defer action to next tick — overlay will be dismissed by then
195
+ setTimeout(action, 0);
196
+ }
197
+
198
+ // ─── Cleanup ───────────────────────────────────────────────────────────────
199
+
200
+ private close(): void {
201
+ this.done();
202
+ }
203
+
204
+ dispose(): void {}
205
+
206
+ render(width: number): string[] {
207
+ const dialogWidth = Math.min(40, Math.max(28, width));
208
+ const innerWidth = dialogWidth - 2;
209
+
210
+ const lines: string[] = [];
211
+
212
+ // Top border
213
+ lines.push(this.theme.fg("borderMuted", `┌${"─".repeat(innerWidth)}┐`));
214
+
215
+ // Title
216
+ const title = this.state === "chord_root" ? "Input Shortcuts" : "Append from register";
217
+ const titlePadded = title.padEnd(innerWidth);
218
+ lines.push(`${this.theme.fg("borderMuted", "│")}${this.theme.fg("accent", titlePadded)}${this.theme.fg("borderMuted", "│")}`);
219
+
220
+ // Separator
221
+ lines.push(this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`));
222
+
223
+ // Action lines
224
+ for (const line of this.actionLines) {
225
+ const rendered = line.render(innerWidth)[0] ?? "";
226
+ const padded = rendered.padEnd(innerWidth);
227
+ lines.push(`${this.theme.fg("borderMuted", "│")}${padded}${this.theme.fg("borderMuted", "│")}`);
228
+ }
229
+
230
+ // Bottom border
231
+ lines.push(this.theme.fg("borderMuted", `└${"─".repeat(innerWidth)}┘`));
232
+
233
+ return lines;
234
+ }
235
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Cross-platform clipboard read/write using child_process.
3
+ * Detection order: xclip → xsel → pbcopy/pbpaste → clip/powershell.
4
+ * Caches detected command on first use.
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+
9
+ export interface ClipboardResult {
10
+ ok: boolean;
11
+ text?: string;
12
+ reason?: string;
13
+ }
14
+
15
+ type ClipboardTool = "xclip" | "xsel" | "pbcopy" | "clip" | "powershell" | null;
16
+
17
+ let cachedTool: ClipboardTool | undefined; // undefined = not yet detected
18
+
19
+ /**
20
+ * Detect available clipboard command. Caches result.
21
+ */
22
+ export function detectClipboard(): ClipboardTool {
23
+ if (cachedTool !== undefined) return cachedTool;
24
+
25
+ const tools: Array<{ name: ClipboardTool; test: string }> = [
26
+ { name: "xclip", test: "xclip -selection clipboard -o" },
27
+ { name: "xsel", test: "xsel --clipboard --output" },
28
+ { name: "pbcopy", test: "pbpaste" },
29
+ { name: "clip", test: "echo test | clip" },
30
+ { name: "powershell", test: "powershell -command Get-Clipboard" },
31
+ ];
32
+
33
+ for (const tool of tools) {
34
+ try {
35
+ execSync(tool.test, { stdio: "ignore", timeout: 2000 });
36
+ cachedTool = tool.name;
37
+ return cachedTool;
38
+ } catch {
39
+ // Try next tool
40
+ }
41
+ }
42
+
43
+ cachedTool = null;
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Copy text to system clipboard.
49
+ */
50
+ export function copyToClipboard(text: string): ClipboardResult {
51
+ const tool = detectClipboard();
52
+ if (!tool) return { ok: false, reason: "clipboard unavailable" };
53
+
54
+ try {
55
+ switch (tool) {
56
+ case "xclip":
57
+ execSync("xclip -selection clipboard", { input: text, timeout: 2000 });
58
+ break;
59
+ case "xsel":
60
+ execSync("xsel --clipboard --input", { input: text, timeout: 2000 });
61
+ break;
62
+ case "pbcopy":
63
+ execSync("pbcopy", { input: text, timeout: 2000 });
64
+ break;
65
+ case "clip":
66
+ execSync("clip", { input: text, timeout: 2000 });
67
+ break;
68
+ case "powershell":
69
+ execSync(`powershell -command "Set-Clipboard -Value '${text.replace(/'/g, "''")}'"`, { timeout: 2000 });
70
+ break;
71
+ }
72
+ return { ok: true };
73
+ } catch {
74
+ return { ok: false, reason: "clipboard write failed" };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Read text from system clipboard.
80
+ */
81
+ export function pasteFromClipboard(): ClipboardResult {
82
+ const tool = detectClipboard();
83
+ if (!tool) return { ok: false, reason: "clipboard unavailable" };
84
+
85
+ try {
86
+ let text: string;
87
+ switch (tool) {
88
+ case "xclip":
89
+ text = execSync("xclip -selection clipboard -o", { encoding: "utf-8", timeout: 2000 }).trimEnd();
90
+ break;
91
+ case "xsel":
92
+ text = execSync("xsel --clipboard --output", { encoding: "utf-8", timeout: 2000 }).trimEnd();
93
+ break;
94
+ case "pbcopy":
95
+ // pbcopy is write-only; use pbpaste for reading
96
+ text = execSync("pbpaste", { encoding: "utf-8", timeout: 2000 }).trimEnd();
97
+ break;
98
+ case "clip":
99
+ // clip is write-only on Windows; use powershell for reading
100
+ text = execSync("powershell -command Get-Clipboard", { encoding: "utf-8", timeout: 2000 }).trimEnd();
101
+ break;
102
+ case "powershell":
103
+ text = execSync("powershell -command Get-Clipboard", { encoding: "utf-8", timeout: 2000 }).trimEnd();
104
+ break;
105
+ default:
106
+ return { ok: false, reason: "clipboard unavailable" };
107
+ }
108
+ return { ok: true, text };
109
+ } catch {
110
+ return { ok: false, reason: "clipboard read failed" };
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Reset cached tool detection (for testing).
116
+ */
117
+ export function resetClipboardCache(): void {
118
+ cachedTool = undefined;
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,411 @@
1
+ /**
2
+ * @pi-unipi/input-shortcuts — Extension entry point
3
+ *
4
+ * Registers ALT+S (chord overlay) and ALT+I (tab insert) shortcuts.
5
+ * Provides /unipi:stash-settings command for keybinding customization.
6
+ *
7
+ * ARCHITECTURE:
8
+ * - The overlay ONLY captures action selection (pure UI, no side effects)
9
+ * - All actions execute OUTSIDE the overlay via callbacks after done()
10
+ * - Undo works via onTerminalInput: snapshots text before each keypress
11
+ * - Cut/Copy: overlay closes immediately, then action runs (non-blocking)
12
+ */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
15
+ import { Key } from "@mariozechner/pi-tui";
16
+ import { MODULES, emitEvent, UNIPI_EVENTS, INPUT_SHORTCUTS_COMMANDS } from "@pi-unipi/core";
17
+ import { RegisterStore } from "./registers.ts";
18
+ import { UndoRedoBuffer } from "./undo-redo.ts";
19
+ import { ChordOverlay, type ChordCallbacks } from "./chord-overlay.ts";
20
+ import { SettingsOverlay } from "./settings-overlay.ts";
21
+ import { loadConfig } from "./settings-overlay.ts";
22
+ import { copyToClipboard } from "./clipboard.ts";
23
+
24
+ // ─── Status feedback ────────────────────────────────────────────────────────
25
+
26
+ const STATUS_KEY = "input-shortcuts";
27
+ const STATUS_SUCCESS_MS = 2000;
28
+ const STATUS_ERROR_MS = 3000;
29
+
30
+ function showSuccess(ctx: ExtensionContext, text: string): void {
31
+ ctx.ui.setStatus(STATUS_KEY, text);
32
+ setTimeout(() => {
33
+ try { ctx.ui.setStatus(STATUS_KEY, undefined); } catch {}
34
+ }, STATUS_SUCCESS_MS);
35
+ }
36
+
37
+ function showError(ctx: ExtensionContext, text: string): void {
38
+ ctx.ui.setStatus(STATUS_KEY, text);
39
+ setTimeout(() => {
40
+ try { ctx.ui.setStatus(STATUS_KEY, undefined); } catch {}
41
+ }, STATUS_ERROR_MS);
42
+ }
43
+
44
+ // ─── Extension ──────────────────────────────────────────────────────────────
45
+
46
+ export default function inputShortcutsExtension(pi: ExtensionAPI): void {
47
+ // Shared state
48
+ const registers = new RegisterStore();
49
+ const undoRedo = new UndoRedoBuffer();
50
+
51
+ // Persistent UI reference (captured on first handler call, persists for session)
52
+ let ui: ExtensionContext["ui"] | null = null;
53
+ let inputListenerRegistered = false;
54
+ let suppressInputListener = false; // set during undo/redo to prevent self-referencing snapshots
55
+
56
+ // ─── Text change detection via onTerminalInput ────────────────────────
57
+ // Snapshots the editor text BEFORE each keypress, enabling undo for typed text.
58
+ //
59
+ // Three independent triggers commit a snapshot (they don't conflict):
60
+ // 1. Pause: user stops typing for 500ms → snapshot the text before this typing session
61
+ // 2. Count: user types 20+ characters since last snapshot → snapshot mid-typing
62
+ // 3. Time: 3 seconds elapsed since last snapshot (even if still typing) → snapshot
63
+ //
64
+ // How it works:
65
+ // - On each keypress, capture text BEFORE the editor processes it
66
+ // - `pendingSnapshot` = text before the current typing session started (first keypress)
67
+ // - `keystrokeCount` = how many edit keys pressed since last snapshot
68
+ // - `lastSnapshotAt` = timestamp of last snapshot commit
69
+ // - 500ms timer resets on each keypress (fires only on pause)
70
+ //
71
+ // Example: user types "hello world" continuously:
72
+ // - Key 1: pendingSnapshot = "", count=1
73
+ // - Key 10: count=10 (no trigger yet)
74
+ // - Key 20: count=20 → COMMIT snapshot="", reset. Now pendingSnapshot=current
75
+ // - User pauses 500ms → COMMIT snapshot=current, reset
76
+ //
77
+ // Example: user types slowly (1 char per second):
78
+ // - Key 1: pendingSnapshot="", count=1, timer starts
79
+ // - Key 2 (1s later): timer was reset, count=2
80
+ // - ... (timer fires after each pause between keys)
81
+ // - Each pause triggers a snapshot
82
+
83
+ let pendingSnapshot: string | null = null;
84
+ let keystrokeCount = 0;
85
+ let lastSnapshotAt = 0;
86
+ let snapshotTimer: ReturnType<typeof setTimeout> | null = null;
87
+
88
+ const SNAPSHOT_PAUSE_MS = 500; // pause trigger
89
+ const SNAPSHOT_COUNT_THRESHOLD = 20; // character count trigger
90
+ const SNAPSHOT_TIME_MS = 3000; // time trigger
91
+
92
+ /** Commit the pending snapshot and reset all tracking state. */
93
+ function commitSnapshot(): void {
94
+ if (pendingSnapshot !== null) {
95
+ undoRedo.snapshot(pendingSnapshot);
96
+ }
97
+ pendingSnapshot = null;
98
+ keystrokeCount = 0;
99
+ lastSnapshotAt = Date.now();
100
+ if (snapshotTimer) {
101
+ clearTimeout(snapshotTimer);
102
+ snapshotTimer = null;
103
+ }
104
+ }
105
+
106
+ function setupInputListener(): void {
107
+ if (inputListenerRegistered || !ui) return;
108
+ inputListenerRegistered = true;
109
+
110
+ ui.onTerminalInput((data: string) => {
111
+ if (!ui || suppressInputListener) return;
112
+
113
+ // Only snapshot for edit keys (printable, backspace, delete, enter)
114
+ const isEditKey = data.length === 1 || data === "\x7f" || data === "\x1b[3~" || data === "\r" || data === "\n";
115
+ if (!isEditKey) return;
116
+
117
+ // Capture text BEFORE the keypress is processed by the editor
118
+ const textBefore = ui.getEditorText();
119
+
120
+ // On first keypress of a new session, store the pending snapshot
121
+ if (pendingSnapshot === null) {
122
+ pendingSnapshot = textBefore;
123
+ lastSnapshotAt = Date.now();
124
+ }
125
+
126
+ keystrokeCount++;
127
+
128
+ // ─── Trigger 1: Character count threshold (20 chars) ─────────
129
+ if (keystrokeCount >= SNAPSHOT_COUNT_THRESHOLD) {
130
+ commitSnapshot();
131
+ // Don't return — let the pause timer restart below
132
+ }
133
+
134
+ // ─── Trigger 2: Time threshold (3 seconds) ───────────────────
135
+ if (pendingSnapshot !== null && Date.now() - lastSnapshotAt >= SNAPSHOT_TIME_MS) {
136
+ commitSnapshot();
137
+ }
138
+
139
+ // ─── Trigger 3: Pause timer (500ms after last keypress) ──────
140
+ // Reset the pause timer on each keypress
141
+ if (snapshotTimer) {
142
+ clearTimeout(snapshotTimer);
143
+ }
144
+ snapshotTimer = setTimeout(() => {
145
+ // User stopped typing — commit the snapshot
146
+ if (pendingSnapshot !== null) {
147
+ commitSnapshot();
148
+ }
149
+ }, SNAPSHOT_PAUSE_MS);
150
+ });
151
+ }
152
+
153
+ // ─── Action implementations ───────────────────────────────────────────
154
+ // These run OUTSIDE the overlay — editor API is fully accessible.
155
+
156
+ function doStash(ctx: ExtensionContext): void {
157
+ const text = ctx.ui.getEditorText();
158
+ if (text.length > 0) {
159
+ undoRedo.snapshot(text); // snapshot before clearing
160
+ registers.setStash(text);
161
+ ctx.ui.setEditorText("");
162
+ showSuccess(ctx, "✓ stash saved");
163
+ } else {
164
+ const stash = registers.getStash();
165
+ if (stash.length === 0) {
166
+ showError(ctx, "stash empty");
167
+ return;
168
+ }
169
+ undoRedo.snapshot(""); // snapshot empty state before restoring
170
+ ctx.ui.setEditorText(stash);
171
+ showSuccess(ctx, "✓ stash restored");
172
+ }
173
+ }
174
+
175
+ function doUndo(ctx: ExtensionContext): void {
176
+ suppressInputListener = true;
177
+ const current = ctx.ui.getEditorText();
178
+ const result = undoRedo.undo(current);
179
+ if (result.ok) {
180
+ ctx.ui.setEditorText(result.text);
181
+ showSuccess(ctx, "✓ undo");
182
+ } else {
183
+ showError(ctx, "nothing to undo");
184
+ }
185
+ suppressInputListener = false;
186
+ }
187
+
188
+ function doRedo(ctx: ExtensionContext): void {
189
+ suppressInputListener = true;
190
+ const current = ctx.ui.getEditorText();
191
+ const result = undoRedo.redo(current);
192
+ if (result.ok) {
193
+ ctx.ui.setEditorText(result.text);
194
+ showSuccess(ctx, "✓ redo");
195
+ } else {
196
+ showError(ctx, "nothing to redo");
197
+ }
198
+ suppressInputListener = false;
199
+ }
200
+
201
+ function doAppendRegister(ctx: ExtensionContext, index: number): void {
202
+ const regText = registers.getRegister(index);
203
+ if (regText.length === 0) {
204
+ showError(ctx, `register ${index} empty`);
205
+ return;
206
+ }
207
+ const current = ctx.ui.getEditorText();
208
+ undoRedo.snapshot(current);
209
+ ctx.ui.setEditorText(current + regText);
210
+ showSuccess(ctx, `✓ register ${index} appended`);
211
+ }
212
+
213
+ function doAppendStash(ctx: ExtensionContext): void {
214
+ const stashText = registers.getStash();
215
+ if (stashText.length === 0) {
216
+ showError(ctx, "stash empty");
217
+ return;
218
+ }
219
+ const current = ctx.ui.getEditorText();
220
+ undoRedo.snapshot(current);
221
+ ctx.ui.setEditorText(current + stashText);
222
+ showSuccess(ctx, "✓ stash appended");
223
+ }
224
+
225
+ function doCopy(ctx: ExtensionContext): void {
226
+ const text = ctx.ui.getEditorText();
227
+ if (text.length === 0) {
228
+ showError(ctx, "nothing to copy");
229
+ return;
230
+ }
231
+ const result = copyToClipboard(text);
232
+ if (result.ok) {
233
+ showSuccess(ctx, "✓ copied");
234
+ } else {
235
+ showError(ctx, result.reason ?? "clipboard unavailable");
236
+ }
237
+ }
238
+
239
+ function doCut(ctx: ExtensionContext): void {
240
+ const text = ctx.ui.getEditorText();
241
+ if (text.length === 0) {
242
+ showError(ctx, "nothing to cut");
243
+ return;
244
+ }
245
+ const result = copyToClipboard(text);
246
+ if (result.ok) {
247
+ undoRedo.snapshot(text); // snapshot before clearing
248
+ ctx.ui.setEditorText("");
249
+ showSuccess(ctx, "✓ cut");
250
+ } else {
251
+ showError(ctx, result.reason ?? "clipboard unavailable");
252
+ }
253
+ }
254
+
255
+ function doToggleThinking(): void {
256
+ const current = pi.getThinkingLevel();
257
+ const THINKING_CYCLE = ["off", "low", "medium", "high", "xhigh"] as const;
258
+ const idx = THINKING_CYCLE.indexOf(current as any);
259
+ const nextIdx = idx >= 0 ? (idx + 1) % THINKING_CYCLE.length : 0;
260
+ const next = THINKING_CYCLE[nextIdx];
261
+ pi.setThinkingLevel(next as any);
262
+ // Note: no ctx available here for status, but thinking level is visible in UI
263
+ }
264
+
265
+ // ─── Register ALT+S shortcut — opens chord overlay ─────────────────────
266
+
267
+ pi.registerShortcut(Key.alt("s"), {
268
+ description: "Input shortcuts — stash, undo, redo, copy, cut, toggle thinking",
269
+ handler: async (ctx: ExtensionContext) => {
270
+ if (!ctx.hasUI) return;
271
+
272
+ // Capture persistent UI reference and setup input listener (once)
273
+ if (!ui) {
274
+ ui = ctx.ui;
275
+ setupInputListener();
276
+ }
277
+
278
+ // Suppress input listener while overlay is open.
279
+ // The onTerminalInput handler fires for the ALT+S keypress itself,
280
+ // capturing the current text as a pending snapshot. If we don't suppress,
281
+ // that snapshot would be committed after undo runs, creating a "undo undoes undo" loop.
282
+ suppressInputListener = true;
283
+ if (snapshotTimer) { clearTimeout(snapshotTimer); snapshotTimer = null; }
284
+ pendingSnapshot = null;
285
+ keystrokeCount = 0;
286
+
287
+ void ctx.ui.custom<void>(
288
+ async (tui, theme, keybindings, done) => {
289
+ const wrappedDone = () => {
290
+ suppressInputListener = false;
291
+ done();
292
+ };
293
+
294
+ const callbacks: ChordCallbacks = {
295
+ onStash: () => doStash(ctx),
296
+ onUndo: () => doUndo(ctx),
297
+ onRedo: () => doRedo(ctx),
298
+ onAppendRegister: (index) => doAppendRegister(ctx, index),
299
+ onAppendStash: () => doAppendStash(ctx),
300
+ onCopy: () => doCopy(ctx),
301
+ onCut: () => doCut(ctx),
302
+ onToggleThinking: () => doToggleThinking(),
303
+ };
304
+
305
+ return new ChordOverlay(tui, theme, keybindings, wrappedDone, callbacks);
306
+ },
307
+ {
308
+ overlay: true,
309
+ overlayOptions: {
310
+ width: 42,
311
+ maxHeight: 20,
312
+ anchor: "top-center",
313
+ margin: { top: 2, left: 2, right: 2 },
314
+ },
315
+ },
316
+ );
317
+ },
318
+ });
319
+
320
+ // ─── Register ALT+I shortcut — insert tab ──────────────────────────────
321
+
322
+ pi.registerShortcut(Key.alt("i"), {
323
+ description: "Insert tab character into input",
324
+ handler: async (ctx: ExtensionContext) => {
325
+ const text = ctx.ui.getEditorText();
326
+ ctx.ui.setEditorText(text + "\t");
327
+ },
328
+ });
329
+
330
+ // ─── Register /unipi:stash-settings command ────────────────────────────
331
+
332
+ pi.registerCommand(`unipi:${INPUT_SHORTCUTS_COMMANDS.STASH_SETTINGS}`, {
333
+ description: "Open input shortcuts settings overlay to customize keybindings",
334
+ handler: async (_args: string, ctx: ExtensionContext) => {
335
+ if (!ctx.hasUI) return;
336
+
337
+ void ctx.ui.custom<void>(
338
+ async (_tui, _theme, _keybindings, done) => {
339
+ return new SettingsOverlay(done);
340
+ },
341
+ {
342
+ overlay: true,
343
+ overlayOptions: {
344
+ width: "60%",
345
+ minWidth: 40,
346
+ maxHeight: "50%",
347
+ anchor: "top-center",
348
+ margin: { top: 2, left: 2, right: 2 },
349
+ },
350
+ },
351
+ );
352
+ },
353
+ });
354
+
355
+ // ─── Session lifecycle ─────────────────────────────────────────────────
356
+
357
+ pi.on("session_shutdown", async () => {
358
+ if (snapshotTimer) {
359
+ clearTimeout(snapshotTimer);
360
+ snapshotTimer = null;
361
+ }
362
+ pendingSnapshot = null;
363
+ keystrokeCount = 0;
364
+ lastSnapshotAt = 0;
365
+ undoRedo.clear();
366
+ });
367
+
368
+ // ─── Info-screen registration ────────────────────────────────────────────
369
+
370
+ const globalObj = globalThis as any;
371
+ const registry = globalObj.__unipi_info_registry;
372
+ if (registry) {
373
+ registry.registerGroup({
374
+ id: "input-shortcuts",
375
+ name: "Input Shortcuts",
376
+ icon: "⌨️",
377
+ priority: 115,
378
+ config: {
379
+ showByDefault: true,
380
+ stats: [
381
+ { id: "chordKey", label: "Chord key", show: true },
382
+ { id: "tabInsertKey", label: "Tab insert key", show: true },
383
+ { id: "registersUsed", label: "Registers used", show: true },
384
+ { id: "stashStatus", label: "Stash", show: true },
385
+ ],
386
+ },
387
+ dataProvider: async () => {
388
+ const config = loadConfig();
389
+ let used = 0;
390
+ for (let i = 0; i <= 9; i++) {
391
+ if (registers.getRegister(i).length > 0) used++;
392
+ }
393
+ return {
394
+ chordKey: { value: config.chordKey, detail: "Key to open shortcuts overlay" },
395
+ tabInsertKey: { value: config.tabInsertKey, detail: "Key to insert tab" },
396
+ registersUsed: { value: `${used}/10`, detail: "Non-empty numbered registers" },
397
+ stashStatus: { value: registers.getStash().length > 0 ? "set" : "empty", detail: "Stash register" },
398
+ };
399
+ },
400
+ });
401
+ }
402
+
403
+ // ─── Module ready event ──────────────────────────────────────────────────
404
+
405
+ emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
406
+ name: MODULES.INPUT_SHORTCUTS,
407
+ version: "0.1.0",
408
+ commands: [`unipi:${INPUT_SHORTCUTS_COMMANDS.STASH_SETTINGS}`],
409
+ tools: [],
410
+ });
411
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Register store with JSON file persistence.
3
+ * 10 numbered registers (0-9) + 1 stash register (S).
4
+ * File: .unipi/config/input-shortcuts.json
5
+ * Atomic writes (write to .tmp then rename).
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import type { RegisterData } from "./types.ts";
11
+ import { REGISTERS_FILE } from "./types.ts";
12
+
13
+ const EMPTY_DATA: RegisterData = {
14
+ stash: "",
15
+ registers: ["", "", "", "", "", "", "", "", "", ""],
16
+ };
17
+
18
+ export class RegisterStore {
19
+ private data: RegisterData | null = null;
20
+ private filePath: string;
21
+ private loaded = false;
22
+
23
+ constructor(baseDir?: string) {
24
+ this.filePath = baseDir ? join(baseDir, REGISTERS_FILE) : REGISTERS_FILE;
25
+ }
26
+
27
+ /** Get the stash register contents. */
28
+ getStash(): string {
29
+ this.ensureLoaded();
30
+ return this.data!.stash;
31
+ }
32
+
33
+ /** Set the stash register contents and persist. */
34
+ setStash(text: string): void {
35
+ this.ensureLoaded();
36
+ this.data!.stash = text;
37
+ this.save();
38
+ }
39
+
40
+ /** Get a numbered register (0-9). */
41
+ getRegister(index: number): string {
42
+ if (index < 0 || index > 9) return "";
43
+ this.ensureLoaded();
44
+ return this.data!.registers[index] ?? "";
45
+ }
46
+
47
+ /** Set a numbered register (0-9) and persist. */
48
+ setRegister(index: number, text: string): void {
49
+ if (index < 0 || index > 9) return;
50
+ this.ensureLoaded();
51
+ this.data!.registers[index] = text;
52
+ this.save();
53
+ }
54
+
55
+ /** Lazy load from disk on first access. */
56
+ private ensureLoaded(): void {
57
+ if (this.loaded) return;
58
+ this.loaded = true;
59
+
60
+ try {
61
+ if (existsSync(this.filePath)) {
62
+ const raw = readFileSync(this.filePath, "utf-8");
63
+ const parsed = JSON.parse(raw) as Partial<RegisterData>;
64
+ this.data = {
65
+ stash: typeof parsed.stash === "string" ? parsed.stash : "",
66
+ registers: Array.isArray(parsed.registers) && parsed.registers.length === 10
67
+ ? parsed.registers.map((r) => (typeof r === "string" ? r : ""))
68
+ : [...EMPTY_DATA.registers],
69
+ };
70
+ } else {
71
+ this.data = { ...EMPTY_DATA, registers: [...EMPTY_DATA.registers] };
72
+ }
73
+ } catch {
74
+ this.data = { ...EMPTY_DATA, registers: [...EMPTY_DATA.registers] };
75
+ }
76
+ }
77
+
78
+ /** Atomic write: write to .tmp then rename. */
79
+ private save(): void {
80
+ try {
81
+ const dir = dirname(this.filePath);
82
+ if (!existsSync(dir)) {
83
+ mkdirSync(dir, { recursive: true });
84
+ }
85
+ const tmpPath = this.filePath + ".tmp";
86
+ writeFileSync(tmpPath, JSON.stringify(this.data, null, 2), "utf-8");
87
+ renameSync(tmpPath, this.filePath);
88
+ } catch {
89
+ // Silent fail — register persistence is best-effort
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Settings TUI overlay for customizing shortcut keybindings.
3
+ * Uses SettingsList from pi-tui following compactor pattern.
4
+ * Persists config to .unipi/config/input-shortcuts-config.json.
5
+ */
6
+
7
+ import type { Component } from "@mariozechner/pi-tui";
8
+ import { SettingsList, type SettingItem, type SettingsListTheme } from "@mariozechner/pi-tui";
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import type { InputShortcutsConfig } from "./types.ts";
12
+ import { CONFIG_FILE, DEFAULT_CONFIG } from "./types.ts";
13
+
14
+ // ─── Available ALT key options ───────────────────────────────────────────────
15
+
16
+ const ALT_KEY_OPTIONS = [
17
+ "alt+a", "alt+b", "alt+c", "alt+d", "alt+e", "alt+f", "alt+g",
18
+ "alt+h", "alt+i", "alt+j", "alt+k", "alt+l", "alt+m", "alt+n",
19
+ "alt+o", "alt+p", "alt+q", "alt+r", "alt+s", "alt+t", "alt+u",
20
+ "alt+v", "alt+w", "alt+x", "alt+y", "alt+z",
21
+ ];
22
+
23
+ // Known conflicts — exclude from options
24
+ const CONFLICTS = new Set(["alt+e"]); // alt+e = cursorWordRight
25
+
26
+ const FREE_ALT_KEYS = ALT_KEY_OPTIONS.filter((k) => !CONFLICTS.has(k));
27
+
28
+ // ─── Config persistence ─────────────────────────────────────────────────────
29
+
30
+ /** Load config from disk, returns defaults if missing. */
31
+ export function loadConfig(baseDir?: string): InputShortcutsConfig {
32
+ const filePath = baseDir ? join(baseDir, CONFIG_FILE) : CONFIG_FILE;
33
+ try {
34
+ if (existsSync(filePath)) {
35
+ const raw = readFileSync(filePath, "utf-8");
36
+ const parsed = JSON.parse(raw) as Partial<InputShortcutsConfig>;
37
+ return {
38
+ chordKey: typeof parsed.chordKey === "string" ? parsed.chordKey : DEFAULT_CONFIG.chordKey,
39
+ tabInsertKey: typeof parsed.tabInsertKey === "string" ? parsed.tabInsertKey : DEFAULT_CONFIG.tabInsertKey,
40
+ };
41
+ }
42
+ } catch {
43
+ // Fall through to defaults
44
+ }
45
+ return { ...DEFAULT_CONFIG };
46
+ }
47
+
48
+ /** Save config to disk with atomic write. */
49
+ export function saveConfig(config: InputShortcutsConfig, baseDir?: string): void {
50
+ const filePath = baseDir ? join(baseDir, CONFIG_FILE) : CONFIG_FILE;
51
+ try {
52
+ const dir = dirname(filePath);
53
+ if (!existsSync(dir)) {
54
+ mkdirSync(dir, { recursive: true });
55
+ }
56
+ const tmpPath = filePath + ".tmp";
57
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
58
+ renameSync(tmpPath, filePath);
59
+ } catch {
60
+ // Silent fail — config persistence is best-effort
61
+ }
62
+ }
63
+
64
+ // ─── SettingsOverlay Component ──────────────────────────────────────────────
65
+
66
+ const THEME: SettingsListTheme = {
67
+ label: (text, selected) => (selected ? `\x1b[1;36m${text}\x1b[0m` : text),
68
+ value: (text, selected) => (selected ? `\x1b[1;33m${text}\x1b[0m` : `\x1b[33m${text}\x1b[0m`),
69
+ description: (text) => `\x1b[2m${text}\x1b[0m`,
70
+ cursor: "▸ ",
71
+ hint: (text) => `\x1b[2m${text}\x1b[0m`,
72
+ };
73
+
74
+ export class SettingsOverlay implements Component {
75
+ private list: SettingsList;
76
+ private config: InputShortcutsConfig;
77
+ private baseDir?: string;
78
+ private onSaved?: (config: InputShortcutsConfig) => void;
79
+
80
+ constructor(
81
+ done: () => void,
82
+ baseDir?: string,
83
+ onSaved?: (config: InputShortcutsConfig) => void,
84
+ ) {
85
+ this.baseDir = baseDir;
86
+ this.onSaved = onSaved;
87
+ this.config = loadConfig(baseDir);
88
+
89
+ const items = this.buildItems();
90
+ this.list = new SettingsList(
91
+ items,
92
+ 10,
93
+ THEME,
94
+ (id, newValue) => this.handleChange(id, newValue),
95
+ () => {
96
+ saveConfig(this.config, this.baseDir);
97
+ this.onSaved?.(this.config);
98
+ done();
99
+ },
100
+ );
101
+ }
102
+
103
+ private buildItems(): SettingItem[] {
104
+ return [
105
+ {
106
+ id: "chordKey",
107
+ label: "Chord trigger key",
108
+ description: "Key to open the input shortcuts overlay",
109
+ currentValue: this.config.chordKey,
110
+ values: FREE_ALT_KEYS,
111
+ },
112
+ {
113
+ id: "tabInsertKey",
114
+ label: "Tab insert key",
115
+ description: "Key to insert a literal tab character",
116
+ currentValue: this.config.tabInsertKey,
117
+ values: FREE_ALT_KEYS,
118
+ },
119
+ ];
120
+ }
121
+
122
+ private handleChange(id: string, newValue: string): void {
123
+ if (id === "chordKey") {
124
+ this.config.chordKey = newValue;
125
+ } else if (id === "tabInsertKey") {
126
+ this.config.tabInsertKey = newValue;
127
+ }
128
+ this.list.updateValue(id, newValue);
129
+ }
130
+
131
+ handleInput(data: string): void {
132
+ this.list.handleInput(data);
133
+ }
134
+
135
+ invalidate(): void {
136
+ this.list.invalidate();
137
+ }
138
+
139
+ render(width: number): string[] {
140
+ return this.list.render(width);
141
+ }
142
+ }
package/src/status.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Status bar feedback helper using ctx.ui.setStatus() with auto-clear.
3
+ */
4
+
5
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import { STATUS_SUCCESS_MS, STATUS_ERROR_MS } from "./types.ts";
7
+
8
+ const STATUS_KEY = "input-shortcuts";
9
+
10
+ /** Show a success status message with auto-clear. */
11
+ export function showSuccess(ctx: ExtensionContext, text: string, durationMs = STATUS_SUCCESS_MS): void {
12
+ showStatus(ctx, text, durationMs);
13
+ }
14
+
15
+ /** Show an error status message with auto-clear. */
16
+ export function showError(ctx: ExtensionContext, text: string, durationMs = STATUS_ERROR_MS): void {
17
+ showStatus(ctx, text, durationMs);
18
+ }
19
+
20
+ /** Show status text with auto-clear after duration. */
21
+ export function showStatus(ctx: ExtensionContext, text: string, durationMs: number): void {
22
+ ctx.ui.setStatus(STATUS_KEY, text);
23
+ setTimeout(() => {
24
+ try {
25
+ ctx.ui.setStatus(STATUS_KEY, undefined);
26
+ } catch {
27
+ // Context may be gone — ignore
28
+ }
29
+ }, durationMs);
30
+ }
31
+
32
+ /** Clear the status bar entry immediately. */
33
+ export function clearStatus(ctx: ExtensionContext): void {
34
+ ctx.ui.setStatus(STATUS_KEY, undefined);
35
+ }
package/src/types.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared type definitions for input-shortcuts package.
3
+ */
4
+
5
+ export interface TextSnapshot {
6
+ text: string;
7
+ timestamp: number;
8
+ }
9
+
10
+ export interface RegisterData {
11
+ stash: string;
12
+ registers: string[];
13
+ }
14
+
15
+ export interface InputShortcutsConfig {
16
+ chordKey: string;
17
+ tabInsertKey: string;
18
+ }
19
+
20
+ export type ChordAction =
21
+ | "stash"
22
+ | "redo"
23
+ | "undo"
24
+ | "appendRegister"
25
+ | "appendStash"
26
+ | "copy"
27
+ | "cut"
28
+ | "toggleThinking"
29
+ | "tab";
30
+
31
+ export type ChordState = "idle" | "chord_root" | "chord_reg";
32
+
33
+ export const DEFAULT_CONFIG: InputShortcutsConfig = {
34
+ chordKey: "alt+s",
35
+ tabInsertKey: "alt+i",
36
+ };
37
+
38
+ // ─── Constants ──────────────────────────────────────────────────────────────
39
+
40
+ export const UNDO_DEBOUNCE_MS = 500;
41
+ export const MAX_UNDO_SNAPSHOTS = 50;
42
+ export const STATUS_SUCCESS_MS = 2000;
43
+ export const STATUS_ERROR_MS = 3000;
44
+ export const REGISTERS_FILE = ".unipi/config/input-shortcuts.json";
45
+ export const CONFIG_FILE = ".unipi/config/input-shortcuts-config.json";
46
+
47
+ // Thinking level cycle for toggle action
48
+ export const THINKING_CYCLE = ["off", "low", "medium", "high", "xhigh"] as const;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * In-memory ring buffer for undo/redo with debounce and throttle.
3
+ *
4
+ * - Max 50 snapshots in undo stack
5
+ * - 500ms debounce on snapshot creation
6
+ * - 1s throttle on undo
7
+ * - Redo buffer cleared on new snapshot
8
+ */
9
+
10
+ import type { TextSnapshot } from "./types.ts";
11
+ import { MAX_UNDO_SNAPSHOTS, UNDO_DEBOUNCE_MS } from "./types.ts";
12
+
13
+ export interface UndoRedoResult {
14
+ text: string;
15
+ ok: boolean;
16
+ reason?: string;
17
+ }
18
+
19
+ export class UndoRedoBuffer {
20
+ private undoStack: TextSnapshot[] = [];
21
+ private redoStack: TextSnapshot[] = [];
22
+ private lastSnapshotAt = 0;
23
+ private lastUndoAt = 0;
24
+
25
+ /**
26
+ * Take a snapshot of current text BEFORE it changes.
27
+ * Pushes to undo stack, clears redo stack.
28
+ * 500ms debounce: skips if last snapshot was within 500ms.
29
+ */
30
+ snapshot(text: string): void {
31
+ const now = Date.now();
32
+ if (now - this.lastSnapshotAt < UNDO_DEBOUNCE_MS) return;
33
+
34
+ this.undoStack.push({ text, timestamp: now });
35
+ if (this.undoStack.length > MAX_UNDO_SNAPSHOTS) {
36
+ this.undoStack.shift();
37
+ }
38
+ this.redoStack = [];
39
+ this.lastSnapshotAt = now;
40
+ }
41
+
42
+ /**
43
+ * Undo: pop from undo stack, push current text to redo.
44
+ */
45
+ undo(currentText: string): UndoRedoResult {
46
+ if (this.undoStack.length === 0) {
47
+ return { text: currentText, ok: false, reason: "nothing to undo" };
48
+ }
49
+
50
+ const snapshot = this.undoStack.pop()!;
51
+ this.redoStack.push({ text: currentText, timestamp: Date.now() });
52
+ return { text: snapshot.text, ok: true };
53
+ }
54
+
55
+ /**
56
+ * Redo: pop from redo stack, push current text to undo.
57
+ * No throttle on redo.
58
+ */
59
+ redo(currentText: string): UndoRedoResult {
60
+ if (this.redoStack.length === 0) {
61
+ return { text: currentText, ok: false, reason: "nothing to redo" };
62
+ }
63
+
64
+ const snapshot = this.redoStack.pop()!;
65
+ this.undoStack.push({ text: currentText, timestamp: Date.now() });
66
+ return { text: snapshot.text, ok: true };
67
+ }
68
+
69
+ /** Check if undo stack has entries. */
70
+ hasUndo(): boolean {
71
+ return this.undoStack.length > 0;
72
+ }
73
+
74
+ /** Check if redo stack has entries. */
75
+ hasRedo(): boolean {
76
+ return this.redoStack.length > 0;
77
+ }
78
+
79
+ /** Clear both stacks. Call on session shutdown. */
80
+ clear(): void {
81
+ this.undoStack = [];
82
+ this.redoStack = [];
83
+ this.lastSnapshotAt = 0;
84
+ this.lastUndoAt = 0;
85
+ }
86
+ }