@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 +116 -0
- package/index.ts +5 -0
- package/package.json +50 -0
- package/src/chord-overlay.ts +235 -0
- package/src/clipboard.ts +119 -0
- package/src/index.ts +411 -0
- package/src/registers.ts +92 -0
- package/src/settings-overlay.ts +142 -0
- package/src/status.ts +35 -0
- package/src/types.ts +48 -0
- package/src/undo-redo.ts +86 -0
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
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
|
+
}
|
package/src/clipboard.ts
ADDED
|
@@ -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
|
+
}
|
package/src/registers.ts
ADDED
|
@@ -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;
|
package/src/undo-redo.ts
ADDED
|
@@ -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
|
+
}
|