opencode-cut 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/clipboard.d.ts +2 -0
- package/dist/clipboard.js +5 -0
- package/dist/cut.d.ts +10 -0
- package/dist/cut.js +29 -0
- package/dist/tui.d.ts +4 -0
- package/dist/tui.js +69 -0
- package/package.json +58 -0
- package/src/clipboard.ts +6 -0
- package/src/cut.ts +50 -0
- package/src/tui.ts +76 -0
- package/tui/index.js +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aiev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# opencode-cut
|
|
2
|
+
|
|
3
|
+
OpenCode V2 TUI plugin that makes `Ctrl+X` cut selected text in prompt and dialog inputs.
|
|
4
|
+
|
|
5
|
+
The plugin copies the selected text to the system clipboard first and deletes it only after the clipboard write succeeds. If the input, selection, or focused editor changes while copying, it is left untouched. Repeated cut requests are ignored while a copy is pending.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Add the package to the terminal client plugin list and move OpenCode's default leader key away from `Ctrl+X`:
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
// ~/.config/opencode/cli.json
|
|
13
|
+
{
|
|
14
|
+
"plugins": ["opencode-cut"],
|
|
15
|
+
"keybinds": {
|
|
16
|
+
"leader": "alt+x"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
OpenCode reloads valid `cli.json` changes while running. Restart the terminal client if the plugin is not picked up immediately.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
1. Select text in an OpenCode input with `Shift` plus the arrow keys.
|
|
26
|
+
2. Press `Ctrl+X`.
|
|
27
|
+
3. Paste it elsewhere with that application's paste shortcut (`Ctrl+V` in OpenCode).
|
|
28
|
+
|
|
29
|
+
With no input selection, `Ctrl+X` does nothing. Existing leader shortcuts continue to work with `Alt+X`; for example, `Alt+X`, then `N` creates a new session.
|
|
30
|
+
|
|
31
|
+
## Clipboard behavior
|
|
32
|
+
|
|
33
|
+
The plugin uses the native operating-system clipboard through [`clipboardy`](https://github.com/sindresorhus/clipboardy). Linux requires a working X11 or Wayland clipboard (for example, `xsel` or `wl-copy`); macOS and Windows use their native clipboard tools.
|
|
34
|
+
|
|
35
|
+
If the clipboard write fails, an error toast is shown and the selection is preserved. OSC 52 is not used because sending an escape sequence does not confirm that a terminal accepted the text. Headless/SSH environments need access to a native clipboard, such as X11 forwarding.
|
|
36
|
+
|
|
37
|
+
Requires OpenCode V2. Development and npm tooling require Node.js 20 or newer. The command ID is `aiev.cut` and can be rebound under `keybinds` in `cli.json`. The plugin does not change your leader automatically; keep the `leader` setting above when using `Ctrl+X`.
|
|
38
|
+
|
|
39
|
+
## Local development
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install
|
|
43
|
+
npm run typecheck
|
|
44
|
+
npm test
|
|
45
|
+
npm run build
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Load the working copy by adding its absolute path to `~/.config/opencode/cli.json`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"plugins": ["/absolute/path/to/opencode-cut"]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
package/dist/cut.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EditBufferRenderable } from "@opentui/core";
|
|
2
|
+
export type CutEditor = Pick<EditBufferRenderable, "deleteSelection" | "getSelectedText" | "getSelection" | "hasSelection" | "plainText">;
|
|
3
|
+
export type CutResult = "cut" | "no-selection" | "selection-changed";
|
|
4
|
+
export type CopyText = (text: string) => Promise<unknown>;
|
|
5
|
+
/**
|
|
6
|
+
* Copies and then deletes one editor selection. The selection is checked again
|
|
7
|
+
* after the asynchronous clipboard write so a changed selection is never
|
|
8
|
+
* deleted accidentally.
|
|
9
|
+
*/
|
|
10
|
+
export declare function cutSelectedText(editor: CutEditor, copy?: CopyText, isCurrent?: () => boolean): Promise<CutResult>;
|
package/dist/cut.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { copyText } from "./clipboard.js";
|
|
2
|
+
function sameSelection(left, right) {
|
|
3
|
+
return left !== null && right !== null && left.start === right.start && left.end === right.end;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Copies and then deletes one editor selection. The selection is checked again
|
|
7
|
+
* after the asynchronous clipboard write so a changed selection is never
|
|
8
|
+
* deleted accidentally.
|
|
9
|
+
*/
|
|
10
|
+
export async function cutSelectedText(editor, copy = copyText, isCurrent = () => true) {
|
|
11
|
+
const selection = editor.getSelection();
|
|
12
|
+
if (!selection || !editor.hasSelection())
|
|
13
|
+
return "no-selection";
|
|
14
|
+
const range = { ...selection };
|
|
15
|
+
const content = editor.plainText;
|
|
16
|
+
const text = editor.getSelectedText();
|
|
17
|
+
if (!text)
|
|
18
|
+
return "no-selection";
|
|
19
|
+
await copy(text);
|
|
20
|
+
if (!isCurrent())
|
|
21
|
+
return "selection-changed";
|
|
22
|
+
const currentSelection = editor.getSelection();
|
|
23
|
+
if (editor.plainText !== content ||
|
|
24
|
+
!sameSelection(range, currentSelection) ||
|
|
25
|
+
editor.getSelectedText() !== text) {
|
|
26
|
+
return "selection-changed";
|
|
27
|
+
}
|
|
28
|
+
return editor.deleteSelection() ? "cut" : "selection-changed";
|
|
29
|
+
}
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { cutSelectedText } from "./cut.js";
|
|
2
|
+
export const CUT_COMMAND_ID = "aiev.cut";
|
|
3
|
+
function errorMessage(error) {
|
|
4
|
+
return error instanceof Error ? error.message : String(error);
|
|
5
|
+
}
|
|
6
|
+
const mod = {
|
|
7
|
+
id: "aiev.cut.tui",
|
|
8
|
+
setup(context) {
|
|
9
|
+
let active = true;
|
|
10
|
+
let copying = false;
|
|
11
|
+
const unregister = context.ui.slot({
|
|
12
|
+
append: "app",
|
|
13
|
+
render: () => {
|
|
14
|
+
context.keymap.layer(() => ({
|
|
15
|
+
mode: "global",
|
|
16
|
+
target: () => context.renderer.currentFocusedEditor,
|
|
17
|
+
priority: 100,
|
|
18
|
+
commands: [
|
|
19
|
+
{
|
|
20
|
+
id: CUT_COMMAND_ID,
|
|
21
|
+
title: "Cut selected text",
|
|
22
|
+
description: "Copy the selected input text to the clipboard and remove it",
|
|
23
|
+
group: "Text Editing",
|
|
24
|
+
bind: "ctrl+x",
|
|
25
|
+
palette: true,
|
|
26
|
+
enabled: () => Boolean(context.renderer.currentFocusedEditor?.hasSelection()),
|
|
27
|
+
run: () => {
|
|
28
|
+
if (copying)
|
|
29
|
+
return;
|
|
30
|
+
const editor = context.renderer.currentFocusedEditor;
|
|
31
|
+
if (!editor?.hasSelection())
|
|
32
|
+
return false;
|
|
33
|
+
copying = true;
|
|
34
|
+
return cutSelectedText(editor, undefined, () => active && context.renderer.currentFocusedEditor === editor)
|
|
35
|
+
.then((result) => {
|
|
36
|
+
if (!active || result !== "selection-changed")
|
|
37
|
+
return;
|
|
38
|
+
context.ui.toast.show({
|
|
39
|
+
message: "Input or focus changed while copying; text was copied but not deleted.",
|
|
40
|
+
variant: "warning",
|
|
41
|
+
});
|
|
42
|
+
})
|
|
43
|
+
.catch((error) => {
|
|
44
|
+
if (!active)
|
|
45
|
+
return;
|
|
46
|
+
context.ui.toast.show({
|
|
47
|
+
title: "Cut failed",
|
|
48
|
+
message: `${errorMessage(error)}. The selection was not deleted.`,
|
|
49
|
+
variant: "error",
|
|
50
|
+
});
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
copying = false;
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
bindings: [CUT_COMMAND_ID],
|
|
59
|
+
}));
|
|
60
|
+
return null;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
active = false;
|
|
65
|
+
unregister();
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
export default mod;
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-cut",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode V2 TUI plugin that adds real clipboard-aware cut support to text inputs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./tui": {
|
|
11
|
+
"types": "./dist/tui.d.ts",
|
|
12
|
+
"import": "./tui/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"tui",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "tsx --test tests/*.test.ts",
|
|
26
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"opencode",
|
|
30
|
+
"opencode-plugin",
|
|
31
|
+
"tui",
|
|
32
|
+
"clipboard",
|
|
33
|
+
"cut"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/aiev/opencode-cut.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/aiev/opencode-cut/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/aiev/opencode-cut#readme",
|
|
43
|
+
"author": "aiev",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"clipboardy": "^5.3.2"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@opentui/core": ">=0.5.10"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@opencode/plugin": "^2.0.16",
|
|
53
|
+
"@opentui/core": "^0.5.10",
|
|
54
|
+
"@types/node": "^22.0.0",
|
|
55
|
+
"tsx": "^4.22.3",
|
|
56
|
+
"typescript": "^5.8.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/clipboard.ts
ADDED
package/src/cut.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { EditBufferRenderable } from "@opentui/core"
|
|
2
|
+
import { copyText } from "./clipboard.js"
|
|
3
|
+
|
|
4
|
+
export type CutEditor = Pick<
|
|
5
|
+
EditBufferRenderable,
|
|
6
|
+
"deleteSelection" | "getSelectedText" | "getSelection" | "hasSelection" | "plainText"
|
|
7
|
+
>
|
|
8
|
+
|
|
9
|
+
export type CutResult = "cut" | "no-selection" | "selection-changed"
|
|
10
|
+
export type CopyText = (text: string) => Promise<unknown>
|
|
11
|
+
|
|
12
|
+
function sameSelection(
|
|
13
|
+
left: { start: number; end: number } | null,
|
|
14
|
+
right: { start: number; end: number } | null,
|
|
15
|
+
): boolean {
|
|
16
|
+
return left !== null && right !== null && left.start === right.start && left.end === right.end
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Copies and then deletes one editor selection. The selection is checked again
|
|
21
|
+
* after the asynchronous clipboard write so a changed selection is never
|
|
22
|
+
* deleted accidentally.
|
|
23
|
+
*/
|
|
24
|
+
export async function cutSelectedText(
|
|
25
|
+
editor: CutEditor,
|
|
26
|
+
copy: CopyText = copyText,
|
|
27
|
+
isCurrent: () => boolean = () => true,
|
|
28
|
+
): Promise<CutResult> {
|
|
29
|
+
const selection = editor.getSelection()
|
|
30
|
+
if (!selection || !editor.hasSelection()) return "no-selection"
|
|
31
|
+
|
|
32
|
+
const range = { ...selection }
|
|
33
|
+
const content = editor.plainText
|
|
34
|
+
const text = editor.getSelectedText()
|
|
35
|
+
if (!text) return "no-selection"
|
|
36
|
+
|
|
37
|
+
await copy(text)
|
|
38
|
+
|
|
39
|
+
if (!isCurrent()) return "selection-changed"
|
|
40
|
+
const currentSelection = editor.getSelection()
|
|
41
|
+
if (
|
|
42
|
+
editor.plainText !== content ||
|
|
43
|
+
!sameSelection(range, currentSelection) ||
|
|
44
|
+
editor.getSelectedText() !== text
|
|
45
|
+
) {
|
|
46
|
+
return "selection-changed"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return editor.deleteSelection() ? "cut" : "selection-changed"
|
|
50
|
+
}
|
package/src/tui.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui"
|
|
2
|
+
import { cutSelectedText } from "./cut.js"
|
|
3
|
+
|
|
4
|
+
export const CUT_COMMAND_ID = "aiev.cut"
|
|
5
|
+
|
|
6
|
+
function errorMessage(error: unknown): string {
|
|
7
|
+
return error instanceof Error ? error.message : String(error)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const mod: Plugin.Definition = {
|
|
11
|
+
id: "aiev.cut.tui",
|
|
12
|
+
|
|
13
|
+
setup(context) {
|
|
14
|
+
let active = true
|
|
15
|
+
let copying = false
|
|
16
|
+
const unregister = context.ui.slot({
|
|
17
|
+
append: "app",
|
|
18
|
+
render: () => {
|
|
19
|
+
context.keymap.layer(() => ({
|
|
20
|
+
mode: "global",
|
|
21
|
+
target: () => context.renderer.currentFocusedEditor,
|
|
22
|
+
priority: 100,
|
|
23
|
+
commands: [
|
|
24
|
+
{
|
|
25
|
+
id: CUT_COMMAND_ID,
|
|
26
|
+
title: "Cut selected text",
|
|
27
|
+
description: "Copy the selected input text to the clipboard and remove it",
|
|
28
|
+
group: "Text Editing",
|
|
29
|
+
bind: "ctrl+x",
|
|
30
|
+
palette: true,
|
|
31
|
+
enabled: () => Boolean(context.renderer.currentFocusedEditor?.hasSelection()),
|
|
32
|
+
run: () => {
|
|
33
|
+
if (copying) return
|
|
34
|
+
const editor = context.renderer.currentFocusedEditor
|
|
35
|
+
if (!editor?.hasSelection()) return false
|
|
36
|
+
|
|
37
|
+
copying = true
|
|
38
|
+
return cutSelectedText(
|
|
39
|
+
editor,
|
|
40
|
+
undefined,
|
|
41
|
+
() => active && context.renderer.currentFocusedEditor === editor,
|
|
42
|
+
)
|
|
43
|
+
.then((result) => {
|
|
44
|
+
if (!active || result !== "selection-changed") return
|
|
45
|
+
context.ui.toast.show({
|
|
46
|
+
message: "Input or focus changed while copying; text was copied but not deleted.",
|
|
47
|
+
variant: "warning",
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
.catch((error) => {
|
|
51
|
+
if (!active) return
|
|
52
|
+
context.ui.toast.show({
|
|
53
|
+
title: "Cut failed",
|
|
54
|
+
message: `${errorMessage(error)}. The selection was not deleted.`,
|
|
55
|
+
variant: "error",
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
.finally(() => {
|
|
59
|
+
copying = false
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
bindings: [CUT_COMMAND_ID],
|
|
65
|
+
}))
|
|
66
|
+
return null
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
return () => {
|
|
70
|
+
active = false
|
|
71
|
+
unregister()
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default mod
|