clipboard builtin

This commit is contained in:
booleanmaybe 2026-04-14 21:15:09 -04:00
parent 3c658f7332
commit aefb6a757d
22 changed files with 413 additions and 43 deletions

View file

@ -3,7 +3,7 @@
- [Assign to me](#assign-to-me--plugin-action)
- [Add tag to task](#add-tag-to-task--plugin-action)
- [Custom status + reject action](#custom-status--reject-action)
- [Implement with Claude](#implement-with-claude--pipe-action)
- [Implement with Claude Code](#implement-with-claude-code--pipe-action)
- [Search all tikis](#search-all-tikis--single-lane-plugin)
- [Quick assign](#quick-assign--lane-based-assignment)
- [Stale task detection](#stale-task-detection--time-trigger--plugin)

View file

@ -136,11 +136,12 @@ Use `call(...)` in a value:
create title=call("echo hi")
```
Pipe select results to a shell command:
Pipe select results to a shell command or clipboard:
```sql
select id where id = id() | run("echo $1 | pbcopy")
select id, title where status = "done" | run("myscript $1 $2")
select id where id = id() | clipboard()
select description where id = id() | clipboard()
```
## Before triggers

View file

@ -246,7 +246,7 @@ after update where new.status = "in progress" run("echo hello")
## Shell-related forms
`ruki` includes three shell-related forms:
`ruki` includes four shell-related forms:
**`call(...)`** — a string-returning expression
@ -267,11 +267,20 @@ after update where new.status = "in progress" run("echo hello")
- `|` is a statement suffix, not an operator
- Example: `select id, title where status = "done" | run("myscript $1 $2")`
Pipe `| run(...)` is distinct from trigger `run()`:
**`| clipboard()` on select** — a pipe suffix
| Form | Context | Field access | Substitution |
- Copies selected field values to the system clipboard
- Fields within a row are tab-separated; rows are newline-separated
- Takes no arguments — grammar enforces empty parentheses
- Cross-platform via `atotto/clipboard` (macOS, Linux, Windows)
- Example: `select id where id = id() | clipboard()`
Comparison of pipe targets and trigger actions:
| Form | Context | Field access | Behavior |
|---|---|---|---|
| trigger `run()` | after-trigger action | `old.`, `new.` allowed | expression evaluation |
| pipe `| run()` | select suffix | field refs disallowed in command | positional args `$1`, `$2` |
| trigger `run()` | after-trigger action | `old.`, `new.` allowed | shell execution via expression evaluation |
| pipe `\| run()` | select suffix | field refs disallowed in command | shell execution with positional args `$1`, `$2` |
| pipe `\| clipboard()` | select suffix | n/a (no arguments) | writes rows to system clipboard |
See [Semantics](semantics.md#pipe-actions-on-select) for pipe evaluation model.

View file

@ -63,13 +63,14 @@ delete where id = "TIKI-ABC123"
delete where status = "cancelled" and "old" in tags
```
`select` may pipe results to a shell command:
`select` may pipe results to a shell command or to the clipboard:
```sql
select id, title where status = "done" | run("myscript $1 $2")
select id where id = id() | clipboard()
```
Each row executes the command with field values as positional arguments (`$1`, `$2`).
`| run(...)` executes the command for each row with field values as positional arguments (`$1`, `$2`). `| clipboard()` copies the selected fields to the system clipboard.
## Conditions and expressions

View file

@ -173,8 +173,11 @@ For the detailed type rules and built-ins, see [Types And Values](types-and-valu
```text
select <fields> where <condition> | run(<command>)
select <fields> where <condition> | clipboard()
```
### `| run(...)` — shell execution
Evaluation model:
- The `select` runs first, producing zero or more rows.
@ -203,3 +206,30 @@ myscript "TIKI-ABC123" "Fix bug"
Pipe `| run(...)` on select is distinct from trigger `run()` actions. See [Triggers](triggers.md) for the difference.
### `| clipboard()` — copy to clipboard
Evaluation model:
- The `select` runs first, producing zero or more rows.
- The selected field values are written to the system clipboard.
- Fields within a row are **tab-separated**; rows are **newline-separated**.
- Uses `atotto/clipboard` internally — works on macOS, Linux (requires `xclip` or `xsel`), and Windows.
Rules:
- Explicit field names are required — same restriction as `| run(...)`.
- `clipboard()` takes no arguments — the grammar enforces empty parentheses.
Examples:
```sql
select id where id = id() | clipboard()
select id, title where status = "done" | clipboard()
```
For a single task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the clipboard receives:
```text
TIKI-ABC123 Fix bug
```

View file

@ -50,7 +50,8 @@ statement = selectStmt | createStmt | updateStmt | deleteStmt ;
selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] [ pipeAction ] ;
fieldList = identifier { "," identifier } ;
pipeAction = "|" runAction ;
pipeAction = "|" ( runAction | clipboardAction ) ;
clipboardAction = "clipboard" "(" ")" ;
createStmt = "create" assignment { assignment } ;
orderBy = "order" "by" sortField { "," sortField } ;

View file

@ -248,7 +248,7 @@ The maximum cascade depth is **8**. Termination is graceful — a warning is log
## The run() action
**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms).
**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` and `| clipboard()` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms). Both `run()` and `clipboard()` are pipe-only targets — they cannot appear as trigger actions.
When an after-trigger uses `run(...)`, the command expression is evaluated to a string, then executed:

View file

@ -218,21 +218,23 @@ select where count(select where status = "done" order by priority) >= 1
## Pipe validation errors
Pipe actions on `select` have several restrictions:
Pipe actions (`| run(...)` and `| clipboard()`) on `select` have several restrictions:
`select *` with pipe:
```sql
select * where status = "done" | run("echo $1")
select * where status = "done" | clipboard()
```
Bare `select` with pipe:
```sql
select | run("echo $1")
select | clipboard()
```
Both are rejected because explicit field names are required when using a pipe.
Both are rejected because explicit field names are required when using a pipe. This applies to both `run()` and `clipboard()` targets.
Non-string command:

View file

@ -25,6 +25,12 @@ views:
- key: "a"
label: "Assign to me"
action: update where id = id() set assignee=user()
- key: "y"
label: "Copy ID"
action: select id where id = id() | clipboard()
- key: "Y"
label: "Copy content"
action: select title, description where id = id() | clipboard()
plugins:
- name: Kanban
description: "Move tiki to new status, search, create or delete"
@ -129,3 +135,9 @@ triggers:
before delete
where old.status = "inProgress"
deny "cannot delete an in-progress task — move to backlog or done first"
- description: spawn next occurrence when recurring task completes
ruki: >
after update
where new.status = "done" and old.recurrence is not empty
create title=old.title priority=old.priority tags=old.tags
recurrence=old.recurrence due=next_date(old.recurrence) status="backlog"

View file

@ -231,6 +231,17 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
// non-fatal: continue remaining rows
}
}
case result.Clipboard != nil:
if err := service.ExecuteClipboardPipe(result.Clipboard.Rows); err != nil {
slog.Error("clipboard pipe failed", "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
if pc.statusline != nil {
pc.statusline.SetMessage("copied to clipboard", model.MessageLevelInfo, true)
}
}
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)

2
go.mod
View file

@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/alecthomas/chroma/v2 v2.14.0
github.com/alecthomas/participle/v2 v2.1.4
github.com/atotto/clipboard v0.1.4
github.com/boolean-maybe/navidown v0.4.18
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
@ -25,7 +26,6 @@ require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect

View file

@ -13,14 +13,24 @@ type Statement struct {
Delete *DeleteStmt
}
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...] [| run(...)]".
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...] [| run(...) | clipboard()]".
type SelectStmt struct {
Fields []string // nil = all ("select" or "select *"); non-nil = specific fields
Where Condition // nil = select all
OrderBy []OrderByClause // nil = unordered
Pipe *RunAction // optional pipe suffix: "| run(...)"
Pipe *PipeAction // optional pipe suffix: "| run(...)" or "| clipboard()"
}
// PipeAction is a discriminated union for pipe targets.
// Exactly one variant is non-nil.
type PipeAction struct {
Run *RunAction
Clipboard *ClipboardAction
}
// ClipboardAction represents "clipboard()" as a pipe target.
type ClipboardAction struct{}
// CreateStmt represents "create <field>=<value>...".
type CreateStmt struct {
Assignments []Assignment

View file

@ -34,11 +34,18 @@ func NewExecutor(schema Schema, userFunc func() string, runtime ExecutorRuntime)
// Result holds the output of executing a statement.
// Exactly one variant is non-nil.
type Result struct {
Select *TaskProjection
Update *UpdateResult
Create *CreateResult
Delete *DeleteResult
Pipe *PipeResult
Select *TaskProjection
Update *UpdateResult
Create *CreateResult
Delete *DeleteResult
Pipe *PipeResult
Clipboard *ClipboardResult
}
// ClipboardResult holds the row data from a clipboard-piped select.
// The service layer writes these to the system clipboard.
type ClipboardResult struct {
Rows [][]string
}
// PipeResult holds the shell command and per-row positional args from a piped select.
@ -147,7 +154,12 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result,
}
if sel.Pipe != nil {
return e.buildPipeResult(sel.Pipe, sel.Fields, filtered, tasks)
switch {
case sel.Pipe.Run != nil:
return e.buildPipeResult(sel.Pipe.Run, sel.Fields, filtered, tasks)
case sel.Pipe.Clipboard != nil:
return e.buildClipboardResult(sel.Fields, filtered)
}
}
return &Result{
@ -169,6 +181,18 @@ func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []*
return nil, fmt.Errorf("pipe command must evaluate to string, got %T", cmdVal)
}
rows := buildFieldRows(fields, matched)
return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil
}
func (e *Executor) buildClipboardResult(fields []string, matched []*task.Task) (*Result, error) {
rows := buildFieldRows(fields, matched)
return &Result{Clipboard: &ClipboardResult{Rows: rows}}, nil
}
// buildFieldRows extracts the requested fields from matched tasks as string rows.
// Shared by both run() and clipboard() pipe targets.
func buildFieldRows(fields []string, matched []*task.Task) [][]string {
rows := make([][]string, len(matched))
for i, t := range matched {
row := make([]string, len(fields))
@ -177,8 +201,7 @@ func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []*
}
rows[i] = row
}
return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil
return rows
}
// pipeArgString space-joins list fields (tags, dependsOn) instead of using Go's

View file

@ -23,7 +23,7 @@ type selectGrammar struct {
Fields *fieldNamesGrammar `parser:" | @@ )?"`
Where *orCond `parser:"( 'where' @@ )?"`
OrderBy *orderByGrammar `parser:"@@?"`
Pipe *runGrammar `parser:"( Pipe @@ )?"`
Pipe *pipeTargetGrammar `parser:"( Pipe @@ )?"`
}
// --- order by grammar ---
@ -73,10 +73,19 @@ type actionGrammar struct {
Delete *deleteGrammar `parser:"| @@"`
}
type pipeTargetGrammar struct {
Run *runGrammar `parser:" @@"`
Clipboard *clipboardGrammar `parser:"| @@"`
}
type runGrammar struct {
Command exprGrammar `parser:"'run' '(' @@ ')'"`
}
type clipboardGrammar struct {
Keyword string `parser:"@'clipboard' '(' ')'"`
}
type denyGrammar struct {
Message string `parser:"'deny' @String"`
}

View file

@ -8,7 +8,7 @@ var reserved = [...]string{
"where", "set", "order", "by",
"asc", "desc",
"before", "after", "deny", "run",
"every",
"every", "clipboard",
"and", "or", "not",
"is", "empty", "in",
"any", "all",

View file

@ -17,6 +17,8 @@ func TestIsReserved(t *testing.T) {
{"and", true},
{"old", true},
{"new", true},
{"clipboard", true},
{"CLIPBOARD", true},
{"title", false},
{"priority", false},
{"foobar", false},

View file

@ -18,11 +18,11 @@ func lowerStatement(g *statementGrammar) (*Statement, error) {
return nil, err
}
if g.Select.Pipe != nil {
cmd, err := lowerExpr(&g.Select.Pipe.Command)
pipe, err := lowerPipeTarget(g.Select.Pipe)
if err != nil {
return nil, err
}
s.Pipe = &RunAction{Command: cmd}
s.Pipe = pipe
}
return &Statement{Select: s}, nil
case g.Create != nil:
@ -68,6 +68,21 @@ func lowerSelect(g *selectGrammar) (*SelectStmt, error) {
return &SelectStmt{Fields: fields, Where: where, OrderBy: orderBy}, nil
}
func lowerPipeTarget(g *pipeTargetGrammar) (*PipeAction, error) {
switch {
case g.Run != nil:
cmd, err := lowerExpr(&g.Run.Command)
if err != nil {
return nil, err
}
return &PipeAction{Run: &RunAction{Command: cmd}}, nil
case g.Clipboard != nil:
return &PipeAction{Clipboard: &ClipboardAction{}}, nil
default:
return nil, fmt.Errorf("empty pipe target")
}
}
func lowerCreate(g *createGrammar) (*CreateStmt, error) {
assignments, err := lowerAssignments(g.Assignments)
if err != nil {

View file

@ -112,8 +112,11 @@ func TestLowerPipeProducesSelectAndPipe(t *testing.T) {
if stmt.Select.Pipe == nil {
t.Fatal("expected Select.Pipe non-nil")
}
if stmt.Select.Pipe.Command == nil {
t.Fatal("expected Select.Pipe.Command non-nil")
if stmt.Select.Pipe.Run == nil {
t.Fatal("expected Select.Pipe.Run non-nil")
}
if stmt.Select.Pipe.Run.Command == nil {
t.Fatal("expected Select.Pipe.Run.Command non-nil")
}
}
@ -342,6 +345,178 @@ func TestExecutePipeListFieldSpaceJoined(t *testing.T) {
}
}
// --- clipboard pipe: parser ---
func TestParseClipboardPipe(t *testing.T) {
p := newTestParser()
tests := []struct {
name string
input string
}{
{"basic clipboard", `select id where id = id() | clipboard()`},
{"multi-field clipboard", `select id, title where status = "done" | clipboard()`},
{"clipboard without where", `select id | clipboard()`},
{"clipboard with order by", `select id, priority order by priority | clipboard()`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseAndValidateStatement(tt.input, ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if !stmt.IsPipe() {
t.Fatal("expected IsPipe() true")
}
if !stmt.IsClipboardPipe() {
t.Fatal("expected IsClipboardPipe() true")
}
})
}
}
// --- clipboard pipe: lowering ---
func TestLowerClipboardPipe(t *testing.T) {
p := newTestParser()
stmt, err := p.ParseStatement(`select id | clipboard()`)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if stmt.Select.Pipe == nil {
t.Fatal("expected Select.Pipe non-nil")
}
if stmt.Select.Pipe.Clipboard == nil {
t.Fatal("expected Select.Pipe.Clipboard non-nil")
}
if stmt.Select.Pipe.Run != nil {
t.Fatal("expected Select.Pipe.Run nil for clipboard pipe")
}
}
// --- clipboard pipe: validation ---
func TestClipboardPipeRejectSelectStar(t *testing.T) {
p := newTestParser()
_, err := p.ParseStatement(`select * | clipboard()`)
if err == nil {
t.Fatal("expected error for select * with clipboard pipe")
}
if !strings.Contains(err.Error(), "explicit field names") {
t.Errorf("expected 'explicit field names' error, got: %v", err)
}
}
func TestClipboardPipeRejectBareSelect(t *testing.T) {
p := newTestParser()
_, err := p.ParseStatement(`select | clipboard()`)
if err == nil {
t.Fatal("expected error for bare select with clipboard pipe")
}
if !strings.Contains(err.Error(), "explicit field names") {
t.Errorf("expected 'explicit field names' error, got: %v", err)
}
}
// --- clipboard pipe: semantic ---
func TestIsClipboardPipeMethod(t *testing.T) {
p := newTestParser()
clipStmt, err := p.ParseAndValidateStatement(`select id where id = id() | clipboard()`, ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if !clipStmt.IsPipe() {
t.Error("expected IsPipe() = true")
}
if !clipStmt.IsClipboardPipe() {
t.Error("expected IsClipboardPipe() = true")
}
runStmt, err := p.ParseAndValidateStatement(`select id where id = id() | run("echo $1")`, ExecutorRuntimePlugin)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if !runStmt.IsPipe() {
t.Error("expected IsPipe() = true for run pipe")
}
if runStmt.IsClipboardPipe() {
t.Error("expected IsClipboardPipe() = false for run pipe")
}
plainStmt, err := p.ParseAndValidateStatement(`select where status = "done"`, ExecutorRuntimeCLI)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if plainStmt.IsClipboardPipe() {
t.Error("expected IsClipboardPipe() = false for plain select")
}
}
// --- clipboard pipe: executor ---
func TestExecuteClipboardPipeReturnsResult(t *testing.T) {
e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
p := newTestParser()
tasks := makeTasks()
stmt, err := p.ParseStatement(`select id, title where status = "done" | clipboard()`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks, ExecutionInput{SelectedTaskID: "TIKI-000003"})
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.Clipboard == nil {
t.Fatal("expected Clipboard result")
}
if result.Pipe != nil {
t.Fatal("expected Pipe to be nil when clipboard is present")
}
if result.Select != nil {
t.Fatal("expected Select to be nil when clipboard is present")
}
if len(result.Clipboard.Rows) != 1 {
t.Fatalf("expected 1 row (status=done), got %d", len(result.Clipboard.Rows))
}
row := result.Clipboard.Rows[0]
if len(row) != 2 {
t.Fatalf("expected 2 fields, got %d", len(row))
}
if row[0] != "TIKI-000003" {
t.Errorf("row[0] (id) = %q, want %q", row[0], "TIKI-000003")
}
if row[1] != "Write docs" {
t.Errorf("row[1] (title) = %q, want %q", row[1], "Write docs")
}
}
func TestExecuteClipboardMultipleRows(t *testing.T) {
e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimeCLI})
p := newTestParser()
tasks := makeTasks()
stmt, err := p.ParseStatement(`select id, title | clipboard()`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.Clipboard == nil {
t.Fatal("expected Clipboard result")
}
if len(result.Clipboard.Rows) != len(tasks) {
t.Fatalf("expected %d rows, got %d", len(tasks), len(result.Clipboard.Rows))
}
}
// --- pipeArgString ---
func TestPipeArgString(t *testing.T) {

View file

@ -49,6 +49,9 @@ func (v *ValidatedStatement) IsDelete() bool {
func (v *ValidatedStatement) IsPipe() bool {
return v != nil && v.statement != nil && v.statement.Select != nil && v.statement.Select.Pipe != nil
}
func (v *ValidatedStatement) IsClipboardPipe() bool {
return v.IsPipe() && v.statement.Select.Pipe.Clipboard != nil
}
func (v *ValidatedStatement) mustBeSealed() error {
if v == nil || v.seal != validatedSeal || v.statement == nil {
@ -391,8 +394,8 @@ func scanSelectSemantics(sel *SelectStmt) (usesID bool, hasCall bool, err error)
if err != nil {
return false, false, err
}
if sel.Pipe != nil {
u2, c2, err := scanExprSemantics(sel.Pipe.Command)
if sel.Pipe != nil && sel.Pipe.Run != nil {
u2, c2, err := scanExprSemantics(sel.Pipe.Run.Command)
if err != nil {
return false, false, err
}
@ -586,7 +589,13 @@ func cloneSelect(sel *SelectStmt) *SelectStmt {
OrderBy: orderBy,
}
if sel.Pipe != nil {
out.Pipe = &RunAction{Command: cloneExpr(sel.Pipe.Command)}
out.Pipe = &PipeAction{}
if sel.Pipe.Run != nil {
out.Pipe.Run = &RunAction{Command: cloneExpr(sel.Pipe.Run.Command)}
}
if sel.Pipe.Clipboard != nil {
out.Pipe.Clipboard = &ClipboardAction{}
}
}
return out
}

View file

@ -74,16 +74,19 @@ func (p *Parser) validateStatement(s *Statement) error {
if len(s.Select.Fields) == 0 {
return fmt.Errorf("pipe requires explicit field names in select (not select * or bare select)")
}
typ, err := p.inferExprType(s.Select.Pipe.Command)
if err != nil {
return fmt.Errorf("pipe command: %w", err)
}
if typ != ValueString {
return fmt.Errorf("pipe command must be string, got %s", typeName(typ))
}
if exprContainsFieldRef(s.Select.Pipe.Command) {
return fmt.Errorf("pipe command must not contain field references — use $1, $2 for positional args")
if s.Select.Pipe.Run != nil {
typ, err := p.inferExprType(s.Select.Pipe.Run.Command)
if err != nil {
return fmt.Errorf("pipe command: %w", err)
}
if typ != ValueString {
return fmt.Errorf("pipe command must be string, got %s", typeName(typ))
}
if exprContainsFieldRef(s.Select.Pipe.Run.Command) {
return fmt.Errorf("pipe command must not contain field references — use $1, $2 for positional args")
}
}
// clipboard() has no arguments — grammar enforces empty parens
}
return nil
default:

17
service/clipboard.go Normal file
View file

@ -0,0 +1,17 @@
package service
import (
"strings"
"github.com/atotto/clipboard"
)
// ExecuteClipboardPipe writes the given rows to the system clipboard.
// Fields within a row are tab-separated; rows are newline-separated.
func ExecuteClipboardPipe(rows [][]string) error {
lines := make([]string, len(rows))
for i, row := range rows {
lines[i] = strings.Join(row, "\t")
}
return clipboard.WriteAll(strings.Join(lines, "\n"))
}

40
service/clipboard_test.go Normal file
View file

@ -0,0 +1,40 @@
package service
import (
"testing"
"github.com/atotto/clipboard"
)
func TestExecuteClipboardPipe(t *testing.T) {
// skip on headless CI where clipboard is not available
if err := clipboard.WriteAll("test"); err != nil {
t.Skipf("clipboard not available: %v", err)
}
tests := []struct {
name string
rows [][]string
want string
}{
{"single field single row", [][]string{{"TIKI-000001"}}, "TIKI-000001"},
{"multi-field single row", [][]string{{"TIKI-000001", "Fix bug"}}, "TIKI-000001\tFix bug"},
{"multi-row", [][]string{{"TIKI-000001", "Fix bug"}, {"TIKI-000002", "Add tests"}}, "TIKI-000001\tFix bug\nTIKI-000002\tAdd tests"},
{"empty rows", [][]string{}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ExecuteClipboardPipe(tt.rows); err != nil {
t.Fatalf("ExecuteClipboardPipe() error: %v", err)
}
got, err := clipboard.ReadAll()
if err != nil {
t.Fatalf("clipboard.ReadAll() error: %v", err)
}
if got != tt.want {
t.Errorf("clipboard = %q, want %q", got, tt.want)
}
})
}
}