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 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
@@ -0,0 +1,2 @@
1
+ /** A terminal escape write alone cannot confirm a successful clipboard copy. */
2
+ export declare function copyText(text: string): Promise<void>;
@@ -0,0 +1,5 @@
1
+ import clipboard from "clipboardy";
2
+ /** A terminal escape write alone cannot confirm a successful clipboard copy. */
3
+ export async function copyText(text) {
4
+ await clipboard.write(text);
5
+ }
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
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from "@opencode/plugin/tui";
2
+ export declare const CUT_COMMAND_ID = "aiev.cut";
3
+ declare const mod: Plugin.Definition;
4
+ export default mod;
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
+ }
@@ -0,0 +1,6 @@
1
+ import clipboard from "clipboardy"
2
+
3
+ /** A terminal escape write alone cannot confirm a successful clipboard copy. */
4
+ export async function copyText(text: string): Promise<void> {
5
+ await clipboard.write(text)
6
+ }
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
package/tui/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Stable TUI entry point. The TypeScript implementation is built to dist/.
2
+ import mod from "../dist/tui.js"
3
+
4
+ export default {
5
+ id: "aiev.cut.tui",
6
+ setup: mod.setup,
7
+ }