claude-plugin-viban 1.0.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/bin/viban ADDED
@@ -0,0 +1,1242 @@
1
+ #!/bin/zsh
2
+ # viban - Vibe Kanban TUI (Notion-style)
3
+ # Requires: gum (brew install gum), jq
4
+ setopt EXTENDED_GLOB
5
+
6
+ # ============================================================
7
+ # Dependency Check (Early exit with helpful messages)
8
+ # ============================================================
9
+ check_dependencies() {
10
+ local missing=0
11
+
12
+ if ! command -v jq &>/dev/null; then
13
+ echo "❌ jq not found"
14
+ [[ "$OSTYPE" == "darwin"* ]] && echo " Install: brew install jq"
15
+ [[ "$OSTYPE" == "linux"* ]] && echo " Install: apt install jq"
16
+ missing=1
17
+ fi
18
+
19
+ if ! command -v gum &>/dev/null; then
20
+ echo "❌ gum not found"
21
+ [[ "$OSTYPE" == "darwin"* ]] && echo " Install: brew install gum"
22
+ [[ "$OSTYPE" == "linux"* ]] && echo " Install: See https://github.com/charmbracelet/gum#installation"
23
+ missing=1
24
+ fi
25
+
26
+ if [[ $missing -eq 1 ]]; then
27
+ echo ""
28
+ echo "Please install missing dependencies and try again."
29
+ exit 1
30
+ fi
31
+ }
32
+
33
+ # Only check dependencies for interactive commands (not help)
34
+ [[ "$1" != "help" && "$1" != "--help" && "$1" != "-h" ]] && check_dependencies
35
+
36
+ cleanup() {
37
+ printf '\033[?25h\033[0m'
38
+ stty echo 2>/dev/null
39
+ clear
40
+ exit 0
41
+ }
42
+ trap cleanup INT TERM EXIT
43
+
44
+ # Prevent gum from querying terminal colors
45
+ export CLICOLOR_FORCE=1
46
+ export COLORTERM=truecolor
47
+ export TERM=${TERM:-xterm-256color}
48
+
49
+ # ============================================================
50
+ # Data Path Detection (with edge case handling)
51
+ # ============================================================
52
+ # Priority:
53
+ # 1. VIBAN_DATA_DIR env var (explicit override)
54
+ # 2. Git common dir (shared across worktrees)
55
+ # 3. Project root .viban/ directory (non-git projects)
56
+
57
+ VIBAN_DATA_DIR="${VIBAN_DATA_DIR:-}"
58
+ VIBAN_IS_GIT_REPO=false
59
+
60
+ if [[ -z "$VIBAN_DATA_DIR" ]]; then
61
+ # Try git common dir first (shared across worktrees)
62
+ if _git_common="$(git rev-parse --git-common-dir 2>/dev/null)"; then
63
+ VIBAN_IS_GIT_REPO=true
64
+ if [[ -d "$_git_common" ]]; then
65
+ VIBAN_DATA_DIR="$(cd "$_git_common" && pwd)"
66
+ fi
67
+ fi
68
+ # Fallback: project root .viban directory
69
+ if [[ -z "$VIBAN_DATA_DIR" ]]; then
70
+ VIBAN_DATA_DIR="${PWD}/.viban"
71
+ fi
72
+ fi
73
+
74
+ VIBAN_JSON="${VIBAN_DATA_DIR}/viban.json"
75
+
76
+ # Ensure data directory exists
77
+ mkdir -p "$VIBAN_DATA_DIR"
78
+
79
+ # ============================================================
80
+ # Initialize viban.json if not exists
81
+ # ============================================================
82
+ init_viban_json() {
83
+ if [[ ! -f "$VIBAN_JSON" ]]; then
84
+ cat > "$VIBAN_JSON" << 'EOF'
85
+ {
86
+ "version": 2,
87
+ "next_id": 1,
88
+ "issues": []
89
+ }
90
+ EOF
91
+ echo "✨ Initialized new viban board at: $VIBAN_JSON"
92
+ fi
93
+ }
94
+
95
+ # Auto-initialize for commands that need data (not help/init)
96
+ case "$1" in
97
+ help|--help|-h|init) ;;
98
+ *) init_viban_json ;;
99
+ esac
100
+
101
+ # Colors - Sunset Orange Theme
102
+ typeset -A C
103
+ C=(
104
+ fg "#FFE5D9"
105
+ fg_dim "#B89685"
106
+ backlog "#8B7B6B"
107
+ progress "#FF6B35"
108
+ review "#C44536"
109
+ card_bg "#2D2416"
110
+ card_bd "#5A4A3A"
111
+ selected "#FF8C42"
112
+ accent "#F7931E"
113
+ )
114
+
115
+ # 3 statuses only
116
+ typeset -A STATUS_LABEL STATUS_COLOR
117
+ STATUS_LABEL=(backlog "To-Do" in_progress "In Progress" review "Human Review")
118
+ STATUS_COLOR=(backlog "${C[backlog]}" in_progress "${C[progress]}" review "${C[review]}")
119
+
120
+ # Priority levels (P0=Critical, P3=Good to have)
121
+ typeset -A PRIORITY_LABEL PRIORITY_COLOR
122
+ PRIORITY_LABEL=(P0 "CRITICAL" P1 "HIGH" P2 "MEDIUM" P3 "LOW")
123
+ PRIORITY_COLOR=(P0 "\033[38;2;255;69;58m" P1 "\033[38;2;255;159;10m" P2 "\033[38;2;255;214;10m" P3 "\033[38;2;142;142;147m")
124
+
125
+ # Issue types (displayed as tags alongside priority)
126
+ typeset -A TYPE_LABEL TYPE_COLOR
127
+ TYPE_LABEL=(bug "BUG" feat "FEAT" chore "CHORE" refactor "REFAC")
128
+ TYPE_COLOR=(bug "\033[38;2;255;69;58m" feat "\033[38;2;50;215;75m" chore "\033[38;2;142;142;147m" refactor "\033[38;2;90;200;250m")
129
+
130
+ VIBAN_STATUSES=(backlog in_progress review)
131
+
132
+ # Pre-generate horizontal borders (cache) - optimized with printf repeat
133
+ typeset -A BORDER_CACHE
134
+ gen_border() {
135
+ local w=$1
136
+ [[ -n "${BORDER_CACHE[$w]}" ]] && { echo "${BORDER_CACHE[$w]}"; return; }
137
+ # Use printf with dynamic width - single call instead of loop
138
+ local b=$(printf '─%.0s' {1..$w})
139
+ BORDER_CACHE[$w]="$b"
140
+ echo "$b"
141
+ }
142
+
143
+ # Cached terminal dimensions (with sensible defaults)
144
+ CACHED_TERM_W=100
145
+ CACHED_TERM_H=30
146
+ CACHED_COL_W=32
147
+ CACHED_MAX_H=22
148
+ CACHED_MAX_TASKS=7
149
+
150
+ # Spinner for in_progress cards (ASCII to avoid width calculation issues)
151
+ SPINNER_FRAMES=('|' '/' '-' '\')
152
+ SPINNER_IDX=0
153
+
154
+ update_term_cache() {
155
+ if [[ -n "$COLUMNS" ]]; then
156
+ CACHED_TERM_W=$COLUMNS
157
+ elif command -v stty &>/dev/null; then
158
+ CACHED_TERM_W=$(stty size 2>/dev/null | cut -d' ' -f2)
159
+ else
160
+ CACHED_TERM_W=$(tput cols 2>/dev/null || echo 100)
161
+ fi
162
+ if [[ -n "$LINES" ]]; then
163
+ CACHED_TERM_H=$LINES
164
+ elif command -v stty &>/dev/null; then
165
+ CACHED_TERM_H=$(stty size 2>/dev/null | cut -d' ' -f1)
166
+ else
167
+ CACHED_TERM_H=$(tput lines 2>/dev/null || echo 30)
168
+ fi
169
+ CACHED_COL_W=$(( (CACHED_TERM_W - 2) / 3 ))
170
+ CACHED_MAX_H=$((CACHED_TERM_H - 8))
171
+ CACHED_MAX_TASKS=$((CACHED_MAX_H / 5))
172
+ (( CACHED_MAX_TASKS < 2 )) && CACHED_MAX_TASKS=2
173
+ (( CACHED_MAX_TASKS > 8 )) && CACHED_MAX_TASKS=8
174
+ }
175
+
176
+ check_deps() {
177
+ command -v gum &>/dev/null || { echo "Error: gum required"; exit 1; }
178
+ command -v jq &>/dev/null || { echo "Error: jq required"; exit 1; }
179
+ }
180
+
181
+ init_json() {
182
+ if [[ ! -f "$VIBAN_JSON" ]]; then
183
+ local max_wt_id=0
184
+ if [[ -d "$VIBAN_DATA_DIR/worktrees" ]]; then
185
+ for d in "$VIBAN_DATA_DIR/worktrees/"*(/N); do
186
+ local wt_id="${d:t}"
187
+ [[ "$wt_id" =~ ^[0-9]+$ ]] && (( wt_id > max_wt_id )) && max_wt_id=$wt_id
188
+ done
189
+ fi
190
+ local next_id=$((max_wt_id + 1))
191
+ echo "{\"version\":2,\"next_id\":$next_id,\"issues\":[]}" > "$VIBAN_JSON"
192
+ elif [[ $(jq '.version // 1' "$VIBAN_JSON") -lt 2 ]]; then
193
+ jq '{
194
+ version: 2,
195
+ next_id: (([.issues[].id] | max // 0) + 1),
196
+ issues: .issues
197
+ }' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
198
+ fi
199
+ }
200
+
201
+ get_next_id() { jq -r '.next_id // (([.issues[].id] | max // 0) + 1)' "$VIBAN_JSON"; }
202
+
203
+ # Calculate effective order for sorting (priority-based virtual order for cards without order)
204
+ # Used internally for fractional indexing calculations
205
+ # Cards with order: use actual order
206
+ # Cards without order: P0=1000000, P1=2000000, P2=3000000, P3=4000000 + id
207
+ calc_effective_order() {
208
+ local order="$1"
209
+ local priority="${2:-P3}"
210
+ local id="$3"
211
+
212
+ if [[ -n "$order" && "$order" != "null" ]]; then
213
+ echo "$order"
214
+ else
215
+ local base_order
216
+ case "$priority" in
217
+ P0) base_order=1000000;;
218
+ P1) base_order=2000000;;
219
+ P2) base_order=3000000;;
220
+ *) base_order=4000000;;
221
+ esac
222
+ echo $((base_order + id))
223
+ fi
224
+ }
225
+
226
+ add_issue() {
227
+ local title=$(gum input --placeholder "Enter task title..." --width 50 \
228
+ --prompt.foreground "${C[accent]}" --cursor.foreground "${C[selected]}")
229
+ [[ -z "$title" ]] && return
230
+
231
+ # Select type
232
+ local issue_type=$(gum choose "bug (BUG)" "feat (FEATURE)" "chore (CHORE)" "refactor (REFACTOR)" \
233
+ --header "Select type:" --cursor.foreground "${C[selected]}")
234
+ issue_type="${issue_type%% *}" # Extract bug, feat, chore, or refactor
235
+ [[ -z "$issue_type" ]] && issue_type="feat"
236
+
237
+ # Select priority
238
+ local priority=$(gum choose "P0 (CRITICAL)" "P1 (HIGH)" "P2 (MEDIUM)" "P3 (LOW)" \
239
+ --header "Select priority:" --cursor.foreground "${C[selected]}")
240
+ priority="${priority%% *}" # Extract P0, P1, P2, or P3
241
+ [[ -z "$priority" ]] && priority="P3"
242
+
243
+ local desc=""
244
+ if gum confirm "Add description?" --affirmative "Yes (open editor)" --negative "No" \
245
+ --selected.foreground="#000000" --selected.background "${C[accent]}"; then
246
+ local tmpfile=$(mktemp)
247
+ local editor="${EDITOR:-${VISUAL:-vim}}"
248
+ local next_id=$(get_next_id)
249
+ local today=$(date +"%Y-%m-%d")
250
+ cat > "$tmpfile" <<TEMPLATE
251
+ # ─────────────────────────────────────────────
252
+ # VIBAN Issue #$next_id
253
+ # ─────────────────────────────────────────────
254
+ # Title: $title
255
+ # Priority: $priority
256
+ # Created: $today
257
+ # Status: backlog
258
+ # ─────────────────────────────────────────────
259
+
260
+ # ▼ 아래에 설명을 작성하세요 (이 줄 아래부터 저장됩니다)
261
+
262
+ TEMPLATE
263
+ $editor "$tmpfile"
264
+ desc=$(sed '/^#/d' "$tmpfile" | sed '/./,$!d')
265
+ rm -f "$tmpfile"
266
+ fi
267
+
268
+ local id=$(get_next_id) now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
269
+ # New cards don't have order - they follow priority-based sorting
270
+ # Order is only assigned when manually moved
271
+ local tmpjson=$(mktemp)
272
+ printf '%s' "$desc" > "$tmpjson"
273
+ jq --arg id "$id" --arg title "$title" --rawfile desc "$tmpjson" --arg priority "$priority" --arg issue_type "$issue_type" --arg now "$now" '
274
+ .next_id = ((.next_id // 0) + 1) |
275
+ .issues += [{
276
+ id:($id|tonumber),
277
+ title:$title,
278
+ description:$desc,
279
+ status:"backlog",
280
+ priority:$priority,
281
+ type:$issue_type,
282
+ assigned_to:null,
283
+ created_at:$now,
284
+ updated_at:$now
285
+ }]' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
286
+ rm -f "$tmpjson"
287
+ }
288
+
289
+ # Sort: backlog/in_progress by effective order, review by updated_at desc
290
+ # Effective order: if .order exists -> use it (manually positioned)
291
+ # if .order is null -> priority-based virtual order (P0=1M, P1=2M, P2=3M, P3=4M) + id
292
+ # This ensures: manually ordered cards stay fixed, others follow priority order
293
+ get_issues_by_status() {
294
+ local st="$1"
295
+ if [[ "$st" == "review" ]]; then
296
+ jq -r --arg s "$st" '.issues|map(select(.status==$s))|sort_by(.updated_at)|reverse' "$VIBAN_JSON"
297
+ else
298
+ jq -r --arg s "$st" '
299
+ .issues | map(select(.status==$s)) | sort_by(
300
+ if .order != null then [0, .order]
301
+ else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id]
302
+ end
303
+ )
304
+ ' "$VIBAN_JSON"
305
+ fi
306
+ }
307
+ count_issues_by_status() { jq -r --arg s "$1" '[.issues[]|select(.status==$s)]|length' "$VIBAN_JSON"; }
308
+
309
+ get_term_width() {
310
+ # Try multiple methods to get terminal width
311
+ if [[ -n "$COLUMNS" ]]; then
312
+ echo "$COLUMNS"
313
+ elif command -v stty &>/dev/null; then
314
+ stty size 2>/dev/null | cut -d' ' -f2
315
+ else
316
+ tput cols 2>/dev/null || echo 100
317
+ fi
318
+ }
319
+ get_term_height() {
320
+ if [[ -n "$LINES" ]]; then
321
+ echo "$LINES"
322
+ elif command -v stty &>/dev/null; then
323
+ stty size 2>/dev/null | cut -d' ' -f1
324
+ else
325
+ tput lines 2>/dev/null || echo 30
326
+ fi
327
+ }
328
+
329
+ # Get display width of string (Korean = 2, ASCII = 1)
330
+ # Fully optimized: no subprocesses, pure zsh with LC_ALL=C trick
331
+ str_width() {
332
+ local str="$1"
333
+ local char_count=${#str}
334
+ # Get byte count in C locale (UTF-8): CJK chars use 3 bytes each
335
+ local byte_count
336
+ LC_ALL=C byte_count=${#str}
337
+ # Each CJK char adds 2 extra bytes (3 total - 1 for normal char = 2 extra)
338
+ # Display width = ASCII count + (extra bytes / 2 for CJK chars)
339
+ local multi_byte_chars=$(( (byte_count - char_count) / 2 ))
340
+ echo $(( char_count + multi_byte_chars ))
341
+ }
342
+
343
+ # Truncate string to max display width (optimized)
344
+ # Uses str_width for width calculation to ensure correct byte counting
345
+ truncate_str() {
346
+ local str="$1" max=$2
347
+ local len=${#str}
348
+ local w=$(str_width "$str")
349
+ # If already fits, return as-is
350
+ (( w <= max )) && { echo "$str"; return; }
351
+ # Binary search for truncation point
352
+ local lo=0 hi=$len mid sub_str
353
+ while (( lo < hi )); do
354
+ mid=$(( (lo + hi + 1) / 2 ))
355
+ sub_str="${str:0:$mid}"
356
+ w=$(str_width "$sub_str")
357
+ if (( w <= max )); then
358
+ lo=$mid
359
+ else
360
+ hi=$((mid - 1))
361
+ fi
362
+ done
363
+ echo "${str:0:$lo}"
364
+ }
365
+
366
+ # ANSI color codes - Orange Theme
367
+ A_RESET="\033[0m"
368
+ A_BOLD="\033[1m"
369
+ A_DIM="\033[2m"
370
+ A_FG="\033[38;2;255;229;217m" # Warm cream text
371
+ A_GRAY="\033[38;2;139;123;107m" # Warm gray for backlog
372
+ A_ORANGE="\033[38;2;255;107;53m" # Vibrant orange for in_progress
373
+ A_DEEP_ORANGE="\033[38;2;196;69;54m" # Deep orange for review
374
+ A_ACCENT="\033[38;2;247;147;30m" # Golden accent
375
+ A_SELECTED="\033[38;2;255;140;66m" # Bright selection
376
+
377
+ # Print centered text (uses cached width)
378
+ print_center() {
379
+ local text=$1 color=${2:-$A_FG}
380
+ local w=$CACHED_TERM_W
381
+ (( w == 0 )) && w=$(get_term_width)
382
+ local len=${#text}
383
+ local pad=$(( (w - len) / 2 ))
384
+ printf "%${pad}s${color}%s${A_RESET}\033[K\n" "" "$text"
385
+ }
386
+
387
+ # Draw header with pure ANSI
388
+ draw_header() {
389
+ printf '\033[K\n'
390
+ print_center "VIBAN" "${A_BOLD}${A_ACCENT}"
391
+ print_center "Vibe Kanban" "${A_DIM}"
392
+ printf '\033[K\n'
393
+ }
394
+
395
+ # Get status color code
396
+ get_status_color() {
397
+ case "$1" in
398
+ backlog) echo "$A_GRAY";;
399
+ in_progress) echo "$A_ORANGE";;
400
+ review) echo "$A_DEEP_ORANGE";;
401
+ esac
402
+ }
403
+
404
+ # Build column lines into array (optimized - single jq call, cached borders)
405
+ # $1: status, $2: col_selected, $3: card_selected (-1 if none), $4: max_h, $5: col_w, $6: json_data
406
+ build_column_lines() {
407
+ local st="$1"
408
+ local is_col_selected="${2:-0}"
409
+ local card_sel="${3:--1}"
410
+ local max_h="${4:-20}"
411
+ local col_w="${5:-30}"
412
+ local json_data="$6"
413
+ local label="${STATUS_LABEL[$st]:-Unknown}"
414
+ local color=$(get_status_color "$st")
415
+
416
+ # Single jq call to get all issues for this status (include description, priority, type)
417
+ # Replace newlines/tabs in description to prevent parsing issues
418
+ # Sort: review by updated_at desc, others by effective order (ordered cards first, then priority)
419
+ local sort_expr='sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)'
420
+ [[ "$st" == "review" ]] && sort_expr='sort_by(.updated_at) | reverse'
421
+ local issues_data=$(printf '%s' "$json_data" | jq -r --arg s "$st" "
422
+ .issues | map(select(.status==\$s)) | $sort_expr |
423
+ .[] | \"\\(.id)\t\\(.title)\t\\((.description // \"\") | gsub(\"[\\n\\t\\r]\"; \" \"))\t\\(.priority // \"P3\")\t\\(.type // \"\")\"")
424
+ local count=0
425
+ # Count lines without subprocess using zsh array splitting
426
+ [[ -n "$issues_data" ]] && count=${#${(f)issues_data}}
427
+
428
+ # Header centered in column
429
+ local hdr_text="● $label"
430
+ local hdr_w=$((${#label} + 2))
431
+ local left_pad=$(( (col_w - hdr_w) / 2 ))
432
+ local right_pad=$((col_w - hdr_w - left_pad))
433
+ if (( is_col_selected )); then
434
+ printf "%${left_pad}s${A_BOLD}${A_SELECTED}%s${A_RESET}%${right_pad}s\n" "" "$hdr_text" ""
435
+ # Underline for selected column - use printf repeat pattern
436
+ local underline=$(printf '─%.0s' {1..$hdr_w})
437
+ printf "%${left_pad}s${A_SELECTED}%s${A_RESET}%${right_pad}s\n" "" "$underline" ""
438
+ else
439
+ printf "%${left_pad}s${color}%s${A_RESET}%${right_pad}s\n" "" "$hdr_text" ""
440
+ # Empty line for non-selected columns
441
+ printf "%${col_w}s\n" ""
442
+ fi
443
+
444
+ local lines_used=2
445
+ local card_inner=$((col_w - 4))
446
+ local border=$(gen_border $card_inner)
447
+
448
+ # Task cards (5 lines: top border, title, desc/priority, empty, bottom border)
449
+ local shown=0
450
+ while IFS=$'\t' read -r id title desc priority issue_type; do
451
+ [[ -z "$id" ]] && continue
452
+ (( shown >= CACHED_MAX_TASKS )) && {
453
+ local more_text=" +$((count - shown)) more..."
454
+ local more_w=${#more_text}
455
+ printf "${A_DIM}%s${A_RESET}%$((col_w - more_w))s\n" "$more_text" ""
456
+ ((lines_used++))
457
+ break
458
+ }
459
+
460
+ # Default priority and type if not set
461
+ [[ -z "$priority" || "$priority" == "null" ]] && priority="P3"
462
+ [[ -z "$issue_type" || "$issue_type" == "null" ]] && issue_type=""
463
+
464
+ # Title line (with spinner for in_progress)
465
+ local spinner_prefix=""
466
+ local spinner_w=0
467
+ if [[ "$st" == "in_progress" ]]; then
468
+ spinner_prefix="${SPINNER_FRAMES[$((SPINNER_IDX % ${#SPINNER_FRAMES[@]} + 1))]} "
469
+ spinner_w=2 # char(1) + space(1)
470
+ fi
471
+ local title_w=$((card_inner - 7 - spinner_w))
472
+ local short=$(truncate_str "$title" $title_w)
473
+ local title_content=" ${spinner_prefix}#$id $short"
474
+ local title_content_w=$(str_width "$title_content")
475
+ local title_pad=$((card_inner - title_content_w))
476
+ (( title_pad < 0 )) && title_pad=0
477
+
478
+ # Description line (dimmed, truncated)
479
+ local desc_w=$((card_inner - 4))
480
+ local desc_short=""
481
+ if [[ -n "$desc" && "$desc" != "null" ]]; then
482
+ desc_short=$(truncate_str "$desc" $desc_w)
483
+ fi
484
+ local desc_content=" $desc_short"
485
+ local desc_content_w=$(str_width "$desc_content")
486
+ local desc_pad=$((card_inner - desc_content_w))
487
+ (( desc_pad < 0 )) && desc_pad=0
488
+
489
+ # Priority and type tags on same line (e.g., [P0] [BUG])
490
+ local priority_tag="[$priority]"
491
+ local priority_color="${PRIORITY_COLOR[$priority]:-$A_DIM}"
492
+ local type_tag=""
493
+ local type_color=""
494
+ local tags_content=""
495
+ local tags_w=0
496
+ if [[ -n "$issue_type" ]]; then
497
+ type_tag="[${TYPE_LABEL[$issue_type]:-$issue_type}]"
498
+ type_color="${TYPE_COLOR[$issue_type]:-$A_DIM}"
499
+ # Calculate total width: " [P1] [BUG]"
500
+ tags_w=$((${#priority_tag} + 1 + ${#type_tag}))
501
+ else
502
+ tags_w=${#priority_tag}
503
+ fi
504
+ local tags_pad=$((card_inner - tags_w - 2)) # -2 for leading spaces
505
+
506
+ local border_color="$A_DIM"
507
+ local text_color="$A_FG"
508
+ local desc_color="$A_DIM"
509
+ if (( is_col_selected && shown == card_sel )); then
510
+ border_color="${A_SELECTED}"
511
+ text_color="${A_BOLD}${A_ACCENT}"
512
+ desc_color="${A_ACCENT}"
513
+ fi
514
+
515
+ # 5-line card with priority+type tags on 4th line
516
+ printf " ${border_color}╭%s╮${A_RESET} \n" "$border"
517
+ printf " ${border_color}│${A_RESET}${text_color}%s${A_RESET}%${title_pad}s${border_color}│${A_RESET} \n" "$title_content" ""
518
+ printf " ${border_color}│${A_RESET}${desc_color}%s${A_RESET}%${desc_pad}s${border_color}│${A_RESET} \n" "$desc_content" ""
519
+ if [[ -n "$type_tag" ]]; then
520
+ printf " ${border_color}│${A_RESET} ${priority_color}%s${A_RESET} ${type_color}%s${A_RESET}%${tags_pad}s${border_color}│${A_RESET} \n" "$priority_tag" "$type_tag" ""
521
+ else
522
+ printf " ${border_color}│${A_RESET} ${priority_color}%s${A_RESET}%${tags_pad}s${border_color}│${A_RESET} \n" "$priority_tag" ""
523
+ fi
524
+ printf " ${border_color}╰%s╯${A_RESET} \n" "$border"
525
+
526
+ ((shown++))
527
+ lines_used=$((lines_used + 5))
528
+ done <<< "$issues_data"
529
+
530
+ if (( count == 0 )); then
531
+ local no_text=" No tasks"
532
+ local no_w=${#no_text}
533
+ printf "${A_DIM}%s${A_RESET}%$((col_w - no_w))s\n" "$no_text" ""
534
+ ((lines_used++))
535
+ fi
536
+
537
+ while (( lines_used < max_h )); do
538
+ printf "%${col_w}s\n" ""
539
+ ((lines_used++))
540
+ done
541
+ }
542
+
543
+ # ESC character for ANSI stripping (defined once at script level)
544
+ _ESC=$'\e'
545
+
546
+ # Pad line to exact width with spaces
547
+ # Optimized: use zsh parameter expansion to strip ANSI codes
548
+ pad_to_width() {
549
+ local line="$1"
550
+ local width="$2"
551
+ # Strip ANSI codes: ESC [ followed by numbers/semicolons, ending with letter
552
+ local plain="${line//${_ESC}\[[0-9;]#[a-zA-Z]/}"
553
+ # Calculate display width: char count + (bytes - chars) / 2 for CJK chars
554
+ local char_count=${#plain} byte_count
555
+ LC_ALL=C byte_count=${#plain}
556
+ unset LC_ALL # Restore default locale to prevent affecting subsequent calls
557
+ local display_w=$(( char_count + (byte_count - char_count) / 2 ))
558
+ local pad=$((width - display_w))
559
+ printf '%s' "$line"
560
+ (( pad > 0 )) && printf "%${pad}s" ""
561
+ }
562
+
563
+ # Draw the board (optimized - arrays instead of temp files)
564
+ # $1: col_sel, $2: card_sel, $3: json_data
565
+ draw_board() {
566
+ local col_sel=${1:-0}
567
+ local card_sel=${2:--1}
568
+ local json_data="$3"
569
+ local col_w=$CACHED_COL_W
570
+ local max_h=$CACHED_MAX_H
571
+
572
+ local c1=-1 c2=-1 c3=-1
573
+ case $col_sel in
574
+ 0) c1=$card_sel;;
575
+ 1) c2=$card_sel;;
576
+ 2) c3=$card_sel;;
577
+ esac
578
+
579
+ # Build columns to arrays
580
+ local -a col1 col2 col3
581
+ col1=("${(@f)$(build_column_lines "backlog" $((col_sel == 0)) $c1 $max_h $col_w "$json_data")}")
582
+ col2=("${(@f)$(build_column_lines "in_progress" $((col_sel == 1)) $c2 $max_h $col_w "$json_data")}")
583
+ col3=("${(@f)$(build_column_lines "review" $((col_sel == 2)) $c3 $max_h $col_w "$json_data")}")
584
+
585
+ # Merge line by line
586
+ local i
587
+ for ((i=1; i<=max_h; i++)); do
588
+ pad_to_width "${col1[$i]}" $col_w
589
+ printf "${A_DIM}│${A_RESET}"
590
+ pad_to_width "${col2[$i]}" $col_w
591
+ printf "${A_DIM}│${A_RESET}"
592
+ pad_to_width "${col3[$i]}" $col_w
593
+ printf '\033[K\n'
594
+ done
595
+ }
596
+
597
+ draw_footer() {
598
+ printf '\033[K\n'
599
+ print_center "←→ Column │ ↑↓ Card │ Shift+↑↓ Reorder │ Shift+←→ Move │ Enter Edit │ ⌫ Del │ A Add │ Q Quit" "${A_DIM}"
600
+ }
601
+
602
+ read_key() {
603
+ local key result=""
604
+ # Timeout for spinner animation refresh (0.5s to prevent key drops)
605
+ read -sk1 -t 0.5 key 2>/dev/null || { echo "timeout"; return; }
606
+
607
+ if [[ "$key" == $'\e' ]]; then
608
+ read -sk1 -t 0.1 c2 2>/dev/null
609
+ if [[ "$c2" == "[" ]]; then
610
+ read -sk1 -t 0.1 c3 2>/dev/null
611
+ case "$c3" in
612
+ D) result="left";; C) result="right";;
613
+ A) result="up";; B) result="down";;
614
+ "1")
615
+ # Handle Shift+arrow sequences: ESC[1;2X where X is A/B/C/D
616
+ read -sk1 -t 0.1 c4 2>/dev/null
617
+ if [[ "$c4" == ";" ]]; then
618
+ read -sk1 -t 0.1 c5 2>/dev/null
619
+ read -sk1 -t 0.1 c6 2>/dev/null
620
+ if [[ "$c5" == "2" ]]; then
621
+ case "$c6" in
622
+ A) result="shift_up";;
623
+ B) result="shift_down";;
624
+ C) result="shift_right";;
625
+ D) result="shift_left";;
626
+ esac
627
+ fi
628
+ fi
629
+ ;;
630
+ esac
631
+ elif [[ "$c2" == "]" ]]; then
632
+ # Drain OSC sequence
633
+ while read -sk1 -t 0.01 _ 2>/dev/null; do :; done
634
+ fi
635
+ elif [[ "$key" == "" || "$key" == $'\n' ]]; then
636
+ result="enter"
637
+ elif [[ "$key" == $'\x7f' || "$key" == $'\b' ]]; then
638
+ result="backspace"
639
+ else
640
+ case "$key" in
641
+ q|Q) result="quit";;
642
+ a|A) result="add";;
643
+ esac
644
+ fi
645
+
646
+ echo "$result"
647
+ }
648
+
649
+ # Move card order up or down within a status column (fractional indexing)
650
+ # $1: status, $2: current card index, $3: direction (-1 for up, 1 for down)
651
+ # When manually moved, the card gets an order value to pin its position
652
+ move_card_order() {
653
+ local st="$1"
654
+ local cur_idx="$2"
655
+ local dir="$3"
656
+ local new_idx=$((cur_idx + dir))
657
+
658
+ # Get issues in current effective order (ordered cards first, then priority-sorted)
659
+ local issues=$(jq -r --arg s "$st" '
660
+ .issues | map(select(.status==$s)) | sort_by(
661
+ if .order != null then [0, .order]
662
+ else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id]
663
+ end
664
+ )
665
+ ' "$VIBAN_JSON")
666
+ local cnt=$(printf '%s' "$issues" | jq 'length')
667
+
668
+ # Bounds check
669
+ (( new_idx < 0 || new_idx >= cnt )) && return 1
670
+
671
+ # Get ID of the card to move
672
+ local cur_id=$(printf '%s' "$issues" | jq -r ".[$cur_idx].id")
673
+
674
+ # Calculate effective order for a card (use actual order or virtual priority-based order)
675
+ get_eff_order() {
676
+ local idx=$1
677
+ local order=$(printf '%s' "$issues" | jq -r ".[$idx].order // \"null\"")
678
+ if [[ "$order" != "null" ]]; then
679
+ echo "$order"
680
+ else
681
+ local priority=$(printf '%s' "$issues" | jq -r ".[$idx].priority // \"P3\"")
682
+ local id=$(printf '%s' "$issues" | jq -r ".[$idx].id")
683
+ case "$priority" in
684
+ P0) echo $((1000000 + id));;
685
+ P1) echo $((2000000 + id));;
686
+ P2) echo $((3000000 + id));;
687
+ *) echo $((4000000 + id));;
688
+ esac
689
+ fi
690
+ }
691
+
692
+ # Calculate new order using fractional indexing
693
+ # Place card between the target position and its neighbor
694
+ local new_order
695
+ if (( dir < 0 )); then
696
+ # Moving up: place between target and the one above it
697
+ local target_order=$(get_eff_order $new_idx)
698
+ if (( new_idx == 0 )); then
699
+ # Moving to top: use target_order - 1
700
+ new_order=$(echo "$target_order - 1" | bc)
701
+ else
702
+ local above_order=$(get_eff_order $(($new_idx - 1)))
703
+ new_order=$(echo "scale=6; ($above_order + $target_order) / 2" | bc)
704
+ fi
705
+ else
706
+ # Moving down: place between target and the one below it
707
+ local target_order=$(get_eff_order $new_idx)
708
+ if (( new_idx == cnt - 1 )); then
709
+ # Moving to bottom: use target_order + 1
710
+ new_order=$(echo "$target_order + 1" | bc)
711
+ else
712
+ local below_order=$(get_eff_order $(($new_idx + 1)))
713
+ new_order=$(echo "scale=6; ($target_order + $below_order) / 2" | bc)
714
+ fi
715
+ fi
716
+
717
+ # Update the card's order (this pins it to the new position)
718
+ local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
719
+ jq --argjson cur_id "$cur_id" --argjson new_order "$new_order" --arg now "$now" '
720
+ (.issues[] | select(.id==$cur_id)) |= . + {order:$new_order,updated_at:$now}
721
+ ' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
722
+
723
+ return 0
724
+ }
725
+
726
+ # Get issue ID by status and index
727
+ get_issue_id_by_index() {
728
+ local st=$1 idx=$2
729
+ jq -r --arg s "$st" --argjson i "$idx" '.issues|map(select(.status==$s))|sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)|.[$i].id // empty' "$VIBAN_JSON"
730
+ }
731
+
732
+ # Delete issue by ID (with worktree cleanup)
733
+ delete_issue() {
734
+ local id=$1
735
+ local repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
736
+ local wt_dir="$VIBAN_DATA_DIR/worktrees/$id"
737
+ local branch="viban-$id"
738
+ if [[ -d "$wt_dir" ]]; then
739
+ git -C "$repo_root" worktree remove "$wt_dir" --force 2>/dev/null
740
+ git -C "$repo_root" branch -D "$branch" 2>/dev/null
741
+ fi
742
+ jq --argjson id "$id" 'del(.issues[]|select((.id|tonumber)==$id))' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && \
743
+ mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
744
+ }
745
+
746
+ level1_columns() {
747
+ local col=0 card=0
748
+
749
+ # Hide cursor and disable input echo
750
+ stty -echo 2>/dev/null
751
+ printf '\033[?25l\033[2J\033[H'
752
+
753
+ # Initial cache update
754
+ update_term_cache
755
+
756
+ while true; do
757
+ # Cache JSON data once per frame
758
+ local json_data=$(cat "$VIBAN_JSON")
759
+
760
+ printf '\033[H\033[0m'
761
+ draw_header
762
+ draw_board $col $card "$json_data"
763
+ draw_footer
764
+ printf '\033[J'
765
+
766
+ # Advance spinner
767
+ ((SPINNER_IDX++))
768
+
769
+ local st="${VIBAN_STATUSES[$((col + 1))]}"
770
+ # Use cached json_data for count
771
+ local cnt=$(printf '%s' "$json_data" | jq -r --arg s "$st" '[.issues[]|select(.status==$s)]|length')
772
+
773
+ local key=$(read_key)
774
+ case "$key" in
775
+ left)
776
+ local start_col=$col
777
+ col=$(( (col - 1 + 3) % 3 ))
778
+ # Skip empty columns (but stop if we return to start)
779
+ while (( col != start_col )); do
780
+ local next_st="${VIBAN_STATUSES[$((col + 1))]}"
781
+ local next_cnt=$(printf '%s' "$json_data" | jq -r --arg s "$next_st" '[.issues[]|select(.status==$s)]|length')
782
+ (( next_cnt > 0 )) && break
783
+ col=$(( (col - 1 + 3) % 3 ))
784
+ done
785
+ card=0
786
+ ;;
787
+ right)
788
+ local start_col=$col
789
+ col=$(( (col + 1) % 3 ))
790
+ # Skip empty columns (but stop if we return to start)
791
+ while (( col != start_col )); do
792
+ local next_st="${VIBAN_STATUSES[$((col + 1))]}"
793
+ local next_cnt=$(printf '%s' "$json_data" | jq -r --arg s "$next_st" '[.issues[]|select(.status==$s)]|length')
794
+ (( next_cnt > 0 )) && break
795
+ col=$(( (col + 1) % 3 ))
796
+ done
797
+ card=0
798
+ ;;
799
+ up)
800
+ (( cnt > 0 )) && card=$(( (card - 1 + cnt) % cnt ))
801
+ ;;
802
+ down)
803
+ (( cnt > 0 )) && card=$(( (card + 1) % cnt ))
804
+ ;;
805
+ shift_up)
806
+ if (( cnt > 0 && card > 0 )); then
807
+ # Get card ID before move
808
+ local card_id=$(printf '%s' "$json_data" | jq -r --arg s "$st" --argjson i "$card" \
809
+ '.issues|map(select(.status==$s))|sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)|.[$i].id // empty')
810
+ if move_card_order "$st" $card -1; then
811
+ # Find new index by ID after move (order changes sort position)
812
+ local new_json=$(cat "$VIBAN_JSON")
813
+ card=$(printf '%s' "$new_json" | jq -r --arg s "$st" --argjson id "$card_id" '
814
+ .issues | map(select(.status==$s)) | sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end) |
815
+ to_entries | map(select(.value.id == $id)) | .[0].key // 0
816
+ ')
817
+ fi
818
+ fi
819
+ ;;
820
+ shift_down)
821
+ if (( cnt > 0 && card < cnt - 1 )); then
822
+ # Get card ID before move
823
+ local card_id=$(printf '%s' "$json_data" | jq -r --arg s "$st" --argjson i "$card" \
824
+ '.issues|map(select(.status==$s))|sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)|.[$i].id // empty')
825
+ if move_card_order "$st" $card 1; then
826
+ # Find new index by ID after move (order changes sort position)
827
+ local new_json=$(cat "$VIBAN_JSON")
828
+ card=$(printf '%s' "$new_json" | jq -r --arg s "$st" --argjson id "$card_id" '
829
+ .issues | map(select(.status==$s)) | sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end) |
830
+ to_entries | map(select(.value.id == $id)) | .[0].key // 0
831
+ ')
832
+ fi
833
+ fi
834
+ ;;
835
+ enter)
836
+ if (( cnt > 0 )); then
837
+ local id=$(printf '%s' "$json_data" | jq -r --arg s "$st" --argjson i "$card" \
838
+ '.issues|map(select(.status==$s))|sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)|.[$i].id // empty')
839
+ [[ -n "$id" ]] && {
840
+ printf '\033[?25h'
841
+ stty echo 2>/dev/null
842
+ edit_issue "$id"
843
+ stty -echo 2>/dev/null
844
+ printf '\033[?25l\033[2J\033[H'
845
+ }
846
+ fi
847
+ ;;
848
+ shift_left)
849
+ if (( cnt > 0 && col > 0 )); then
850
+ move_card_status "$st" $card -1 && { col=$((col - 1)); card=0; }
851
+ fi
852
+ ;;
853
+ shift_right)
854
+ if (( cnt > 0 && col < 2 )); then
855
+ move_card_status "$st" $card 1 && { col=$((col + 1)); card=0; }
856
+ fi
857
+ ;;
858
+ add)
859
+ printf '\033[?25h'
860
+ stty echo 2>/dev/null
861
+ add_issue
862
+ stty -echo 2>/dev/null
863
+ printf '\033[?25l\033[2J\033[H'
864
+ ;;
865
+ backspace)
866
+ if (( cnt > 0 )); then
867
+ local id=$(printf '%s' "$json_data" | jq -r --arg s "$st" --argjson i "$card" \
868
+ '.issues|map(select(.status==$s))|sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)|.[$i].id // empty')
869
+ [[ -n "$id" ]] && {
870
+ # Move cursor to footer line and run gum there
871
+ printf '\033[?25h'
872
+ stty echo 2>/dev/null
873
+ # Clear footer line and show confirm
874
+ printf '\033[%d;1H\033[K' "$CACHED_TERM_H"
875
+ if gum confirm "Delete #$id?" --affirmative "Yes" --negative "No" \
876
+ --selected.foreground="#000000" --selected.background "${C[accent]}"; then
877
+ delete_issue "$id"
878
+ (( card > 0 )) && card=$((card - 1))
879
+ fi
880
+ stty -echo 2>/dev/null
881
+ printf '\033[?25l'
882
+ # Redraw footer only (cursor back to footer)
883
+ printf '\033[%d;1H\033[K' "$((CACHED_TERM_H - 1))"
884
+ draw_footer
885
+ }
886
+ fi
887
+ ;;
888
+ quit)
889
+ printf '\033[?25h\033[0m'
890
+ stty echo 2>/dev/null
891
+ clear
892
+ exit 0
893
+ ;;
894
+ esac
895
+ done
896
+ }
897
+
898
+ # Edit issue in editor (title + description + priority + type)
899
+ edit_issue() {
900
+ local id=$1
901
+ local issue=$(jq --argjson id "$id" '.issues[]|select((.id|tonumber)==$id)' "$VIBAN_JSON")
902
+ [[ -z "$issue" ]] && return 1
903
+
904
+ local title=$(printf '%s' "$issue" | jq -r '.title')
905
+ local desc=$(printf '%s' "$issue" | jq -r '.description // ""')
906
+ local ist=$(printf '%s' "$issue" | jq -r '.status')
907
+ local created=$(printf '%s' "$issue" | jq -r '.created_at')
908
+ local priority=$(printf '%s' "$issue" | jq -r '.priority // "P3"')
909
+ local issue_type=$(printf '%s' "$issue" | jq -r '.type // ""')
910
+
911
+ local tmpfile=$(mktemp)
912
+ local editor="${EDITOR:-${VISUAL:-vim}}"
913
+
914
+ cat > "$tmpfile" <<TEMPLATE
915
+ # ─────────────────────────────────────────────
916
+ # VIBAN Issue #$id
917
+ # ─────────────────────────────────────────────
918
+ # Status: ${STATUS_LABEL[$ist]}
919
+ # Created: ${created:0:10}
920
+ # ─────────────────────────────────────────────
921
+
922
+ # ▼ Priority (P0=CRITICAL, P1=HIGH, P2=MEDIUM, P3=LOW)
923
+ $priority
924
+
925
+ # ▼ Type (bug, feat, chore, refactor) - leave empty for none
926
+ $issue_type
927
+
928
+ # ▼ Title (한 줄)
929
+ $title
930
+
931
+ # ▼ Description (여러 줄 가능)
932
+ $desc
933
+ TEMPLATE
934
+
935
+ $editor "$tmpfile"
936
+
937
+ # Parse: priority -> type -> title -> description
938
+ local new_priority="" new_type="" new_title="" new_desc="" parse_stage=0
939
+ while IFS= read -r line; do
940
+ [[ "$line" =~ ^#.*$ ]] && continue
941
+ case $parse_stage in
942
+ 0) # Looking for priority
943
+ [[ -z "$line" ]] && continue
944
+ # Validate priority format (P0-P3)
945
+ if [[ "$line" =~ ^P[0-3]$ ]]; then
946
+ new_priority="$line"
947
+ else
948
+ new_priority="P3" # Default if invalid
949
+ fi
950
+ parse_stage=1
951
+ ;;
952
+ 1) # Looking for type
953
+ [[ -z "$line" ]] && continue # 빈 줄은 무시하고 계속 대기
954
+ # Validate type format (bug, feat, chore, refactor)
955
+ if [[ "$line" =~ ^(bug|feat|chore|refactor)$ ]]; then
956
+ new_type="$line"
957
+ fi
958
+ parse_stage=2 # type이든 아니든 비-빈 줄을 만났으면 stage 2로
959
+ ;;
960
+ 2) # Looking for title
961
+ [[ -z "$line" ]] && continue
962
+ new_title="$line"
963
+ parse_stage=3
964
+ ;;
965
+ 3) # Collecting description
966
+ # Skip empty lines right after title
967
+ if [[ -z "$new_desc" && -z "$line" ]]; then
968
+ continue
969
+ fi
970
+ new_desc+="$line"$'\n'
971
+ ;;
972
+ esac
973
+ done < "$tmpfile"
974
+
975
+ # Trim trailing newlines from description
976
+ new_desc="${new_desc%$'\n'}"
977
+
978
+ rm -f "$tmpfile"
979
+
980
+ [[ -z "$new_title" ]] && return 1
981
+ [[ -z "$new_priority" ]] && new_priority="P3"
982
+
983
+ # Update issue
984
+ local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
985
+ local tmpjson=$(mktemp)
986
+ printf '%s' "$new_desc" > "$tmpjson"
987
+ jq --argjson id "$id" --arg title "$new_title" --rawfile desc "$tmpjson" --arg priority "$new_priority" --arg issue_type "$new_type" --arg now "$now" \
988
+ '(.issues[]|select((.id|tonumber)==$id)) |= . + {title:$title,description:$desc,priority:$priority,type:(if $issue_type == "" then null else $issue_type end),updated_at:$now}' \
989
+ "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
990
+ rm -f "$tmpjson"
991
+ }
992
+
993
+ # Move card to adjacent column (change status)
994
+ move_card_status() {
995
+ local st="$1"
996
+ local card_idx="$2"
997
+ local dir="$3" # -1 for left, 1 for right
998
+
999
+ local id=$(jq -r --arg s "$st" --argjson i "$card_idx" \
1000
+ '.issues|map(select(.status==$s))|sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)|.[$i].id // empty' "$VIBAN_JSON")
1001
+ [[ -z "$id" ]] && return 1
1002
+
1003
+ # Find current status index and calculate new status
1004
+ local cur_idx=0
1005
+ for i in {1..3}; do
1006
+ [[ "${VIBAN_STATUSES[$i]}" == "$st" ]] && { cur_idx=$i; break; }
1007
+ done
1008
+
1009
+ local new_idx=$((cur_idx + dir))
1010
+ (( new_idx < 1 || new_idx > 3 )) && return 1
1011
+
1012
+ local new_st="${VIBAN_STATUSES[$new_idx]}"
1013
+ local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1014
+
1015
+ jq --argjson id "$id" --arg new_st "$new_st" --arg now "$now" \
1016
+ '(.issues[]|select((.id|tonumber)==$id)) |= . + {status:$new_st,updated_at:$now}' \
1017
+ "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1018
+ }
1019
+
1020
+ # CLI commands
1021
+ cmd_list() {
1022
+ init_json
1023
+ echo ""
1024
+ for st in $VIBAN_STATUSES; do
1025
+ gum style --foreground "${STATUS_COLOR[$st]}" --bold "● ${STATUS_LABEL[$st]} ($(count_issues_by_status "$st"))"
1026
+ get_issues_by_status "$st" | jq -r '.[]|" #\(.id) [\(.priority // "P3")]\(if .type then " [\(.type | ascii_upcase)]" else "" end) \(.title)"'
1027
+ echo ""
1028
+ done
1029
+ }
1030
+
1031
+ cmd_priority() {
1032
+ init_json
1033
+ [[ -z "$1" ]] && { echo "Usage: viban priority <id> <P0|P1|P2|P3>"; exit 1; }
1034
+ local id="$1"
1035
+ local new_priority="${2:-}"
1036
+
1037
+ # Validate priority
1038
+ if [[ ! "$new_priority" =~ ^P[0-3]$ ]]; then
1039
+ echo "Error: Priority must be P0, P1, P2, or P3"
1040
+ exit 1
1041
+ fi
1042
+
1043
+ # Check if issue exists
1044
+ local exists=$(jq --argjson id "$id" '[.issues[]|select((.id|tonumber)==$id)]|length' "$VIBAN_JSON")
1045
+ [[ "$exists" == "0" ]] && { echo "Error: Issue #$id not found"; exit 1; }
1046
+
1047
+ local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1048
+ jq --argjson id "$id" --arg priority "$new_priority" --arg now "$now" \
1049
+ '(.issues[]|select((.id|tonumber)==$id)) |= . + {priority:$priority,updated_at:$now}' \
1050
+ "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1051
+
1052
+ echo "✓ #$id priority → $new_priority"
1053
+ }
1054
+
1055
+ cmd_add() {
1056
+ init_json
1057
+ [[ -z "$1" ]] && { echo "Usage: viban add \"title\" [\"description\"] [priority] [type]"; exit 1; }
1058
+ local id=$(get_next_id) now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1059
+ local desc="${2:-}"
1060
+ local priority="${3:-P3}"
1061
+ local issue_type="${4:-}"
1062
+ # Validate priority
1063
+ [[ ! "$priority" =~ ^P[0-3]$ ]] && priority="P3"
1064
+ # Validate type (bug, feat, chore, refactor)
1065
+ [[ -n "$issue_type" && ! "$issue_type" =~ ^(bug|feat|chore|refactor)$ ]] && issue_type=""
1066
+ # New cards don't have order - they follow priority-based sorting
1067
+ # Order is only assigned when manually moved
1068
+ local tmpjson=$(mktemp)
1069
+ printf '%s' "$desc" > "$tmpjson"
1070
+ jq --arg id "$id" --arg title "$1" --rawfile desc "$tmpjson" --arg priority "$priority" --arg issue_type "$issue_type" --arg now "$now" '
1071
+ .next_id = ((.next_id // 0) + 1) |
1072
+ .issues += [{
1073
+ id:($id|tonumber),
1074
+ title:$title,
1075
+ description:$desc,
1076
+ status:"backlog",
1077
+ priority:$priority,
1078
+ type:(if $issue_type == "" then null else $issue_type end),
1079
+ assigned_to:null,
1080
+ created_at:$now,
1081
+ updated_at:$now
1082
+ }]' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1083
+ rm -f "$tmpjson"
1084
+ local type_info=""
1085
+ [[ -n "$issue_type" ]] && type_info=" [$issue_type]"
1086
+ echo "✓ #$id added ($priority)$type_info"
1087
+ }
1088
+
1089
+ cmd_assign() {
1090
+ init_json
1091
+ local session="${1:-$(echo $RANDOM | md5 | head -c 8)}"
1092
+ local issue=$(jq -r '.issues|map(select(.status=="backlog"))|sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)|first' "$VIBAN_JSON")
1093
+ [[ "$issue" == "null" || -z "$issue" ]] && { echo "No backlog"; exit 1; }
1094
+ local id=$(printf '%s' "$issue" | jq -r '.id') now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1095
+
1096
+ # Update status to in_progress (no worktree - use branch workflow)
1097
+ jq --argjson id "$id" --arg s "$session" --arg now "$now" \
1098
+ '(.issues[]|select((.id|tonumber)==$id)) |= . + {status:"in_progress",assigned_to:$s,updated_at:$now}' \
1099
+ "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1100
+
1101
+ # Set iTerm2 session name to issue number
1102
+ printf '\033]1;#%s\007' "$id"
1103
+
1104
+ echo "✓ #$id assigned"
1105
+ echo "$id"
1106
+ }
1107
+
1108
+ cmd_review() {
1109
+ init_json
1110
+ local id="${1:-$(jq -r '.issues|map(select(.status=="in_progress"))|first|.id//empty' "$VIBAN_JSON")}"
1111
+ [[ -z "$id" ]] && { echo "None"; exit 1; }
1112
+ local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1113
+ jq --argjson id "$id" --arg now "$now" \
1114
+ '(.issues[]|select((.id|tonumber)==$id)) |= . + {status:"review",assigned_to:null,updated_at:$now}' \
1115
+ "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1116
+
1117
+ # Clear iTerm2 session name
1118
+ printf '\033]1;\007'
1119
+
1120
+ echo "✓ #$id → review"
1121
+ }
1122
+
1123
+ cmd_done() {
1124
+ init_json
1125
+ [[ -z "$1" ]] && { echo "Usage: viban done <id>"; exit 1; }
1126
+ # Cleanup worktree if exists
1127
+ local repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
1128
+ local wt_dir="$VIBAN_DATA_DIR/worktrees/$1"
1129
+ local branch="viban-$1"
1130
+ if [[ -d "$wt_dir" ]]; then
1131
+ git -C "$repo_root" worktree remove "$wt_dir" --force 2>/dev/null
1132
+ git -C "$repo_root" branch -D "$branch" 2>/dev/null
1133
+ echo "✓ worktree removed"
1134
+ fi
1135
+ # Remove task (handle both string and number ID)
1136
+ jq --argjson id "$1" 'del(.issues[]|select((.id|tonumber)==$id))' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && \
1137
+ mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1138
+
1139
+ # Clear iTerm2 session name
1140
+ printf '\033]1;\007'
1141
+
1142
+ echo "✓ #$1 completed & removed"
1143
+ }
1144
+
1145
+ cmd_get() { init_json; jq --argjson id "$1" '.issues[]|select((.id|tonumber)==$id)' "$VIBAN_JSON"; }
1146
+
1147
+ cmd_migrate() {
1148
+ init_json
1149
+ echo "Migrating issues..."
1150
+ local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1151
+
1152
+ # Migration 1: extract [BUG], [FEATURE], [REFACTOR] from title to type field
1153
+ # Also strip [P0-P3] from title if present (already in priority field)
1154
+ echo " - Extracting type from titles..."
1155
+ jq --arg now "$now" '
1156
+ .issues = [.issues[] |
1157
+ # Extract type from title
1158
+ (if (.title | test("^\\[BUG\\]"; "i")) then "bug"
1159
+ elif (.title | test("^\\[FEATURE\\]"; "i")) then "feat"
1160
+ elif (.title | test("^\\[FEAT\\]"; "i")) then "feat"
1161
+ elif (.title | test("^\\[REFACTOR\\]"; "i")) then "refactor"
1162
+ elif (.title | test("^\\[CHORE\\]"; "i")) then "chore"
1163
+ else .type // null end) as $extracted_type |
1164
+
1165
+ # Clean title: remove [BUG], [FEATURE], [REFACTOR], [CHORE], [P0-P3] prefixes
1166
+ (.title |
1167
+ gsub("^\\[BUG\\]\\s*"; "") |
1168
+ gsub("^\\[FEATURE\\]\\s*"; "") |
1169
+ gsub("^\\[FEAT\\]\\s*"; "") |
1170
+ gsub("^\\[REFACTOR\\]\\s*"; "") |
1171
+ gsub("^\\[CHORE\\]\\s*"; "") |
1172
+ gsub("^\\[P[0-3]\\]\\s*"; "")
1173
+ ) as $clean_title |
1174
+
1175
+ # Update issue
1176
+ . + {
1177
+ title: $clean_title,
1178
+ type: (if $extracted_type then $extracted_type else .type end),
1179
+ updated_at: $now
1180
+ }
1181
+ ]
1182
+ ' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1183
+
1184
+ # Migration 2: Remove order field from all issues
1185
+ # New behavior: order is only set when manually moved, otherwise follows priority
1186
+ echo " - Removing order field (reset to priority-based sorting)..."
1187
+ jq --arg now "$now" '
1188
+ .issues = [.issues[] | del(.order) | . + {updated_at: $now}]
1189
+ ' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1190
+
1191
+ echo "✓ Migration complete"
1192
+ echo ""
1193
+ echo "Summary:"
1194
+ jq -r '
1195
+ [.issues[] | select(.type != null)] | group_by(.type) |
1196
+ .[] | " \(.[0].type): \(length) issues"
1197
+ ' "$VIBAN_JSON"
1198
+ echo " (no type): $(jq '[.issues[] | select(.type == null)] | length' "$VIBAN_JSON") issues"
1199
+ echo ""
1200
+ echo "Issues by priority:"
1201
+ jq -r '
1202
+ [.issues[] | select(.status != "done")] |
1203
+ group_by(.priority // "P3") | sort_by(.[0].priority) |
1204
+ .[] | " \(.[0].priority // "P3"): \(length) issues"
1205
+ ' "$VIBAN_JSON"
1206
+ }
1207
+
1208
+ main() {
1209
+ check_deps
1210
+ init_json
1211
+ case "${1:-}" in
1212
+ list) cmd_list;;
1213
+ add) cmd_add "$2" "$3" "$4" "$5";;
1214
+ assign) cmd_assign "$2";;
1215
+ review) cmd_review "$2";;
1216
+ done) cmd_done "$2";;
1217
+ get) cmd_get "$2";;
1218
+ edit) [[ -z "$2" ]] && { echo "Usage: viban edit <id>"; exit 1; }; edit_issue "$2";;
1219
+ priority) cmd_priority "$2" "$3";;
1220
+ migrate) cmd_migrate;;
1221
+ help|--help|-h)
1222
+ echo "viban - Vibe Kanban"
1223
+ echo ""
1224
+ echo " viban TUI"
1225
+ echo " viban list Show board"
1226
+ echo " viban add \"title\" [\"desc\"] [P0-P3] [type] Add task"
1227
+ echo " viban priority <id> <P0-P3> Set priority"
1228
+ echo " viban assign Assign first backlog (by priority)"
1229
+ echo " viban review → Human Review"
1230
+ echo " viban done <id> Complete & remove"
1231
+ echo " viban edit <id> Edit task in editor"
1232
+ echo " viban migrate Migrate: extract type from title"
1233
+ echo ""
1234
+ echo " Priority: P0=CRITICAL, P1=HIGH, P2=MEDIUM, P3=LOW"
1235
+ echo " Type: bug, feat, chore, refactor"
1236
+ ;;
1237
+ "") level1_columns;;
1238
+ *) echo "Unknown: $1"; exit 1;;
1239
+ esac
1240
+ }
1241
+
1242
+ main "$@"