claude-plugin-viban 1.3.12 → 1.3.13

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 CHANGED
@@ -131,60 +131,6 @@ cleanup() {
131
131
  }
132
132
  trap cleanup INT TERM EXIT
133
133
 
134
- # Python coprocess for TUI rendering (eliminates per-frame spawn overhead)
135
- # Uses explicit file descriptors (fd 7/8) to avoid interfering with read -sk1
136
- _COPROC_PID=""
137
- _COPROC_RESULT=""
138
-
139
- _start_coproc() {
140
- local _in_fifo _out_fifo
141
- _in_fifo=$(mktemp -u /tmp/viban_cp_in.XXXXXX)
142
- _out_fifo=$(mktemp -u /tmp/viban_cp_out.XXXXXX)
143
- mkfifo "$_in_fifo" "$_out_fifo"
144
- python3 "$VIBAN_SCRIPT_DIR/scripts/tui_coprocess.py" < "$_in_fifo" > "$_out_fifo" &
145
- _COPROC_PID=$!
146
- exec 7>"$_in_fifo" 8<"$_out_fifo"
147
- rm -f "$_in_fifo" "$_out_fifo"
148
- }
149
-
150
- _stop_coproc() {
151
- if [[ -n "$_COPROC_PID" ]] && kill -0 "$_COPROC_PID" 2>/dev/null; then
152
- echo "QUIT" >&7 2>/dev/null
153
- exec 7>&- 2>/dev/null
154
- wait "$_COPROC_PID" 2>/dev/null
155
- else
156
- exec 7>&- 2>/dev/null
157
- fi
158
- exec 8<&- 2>/dev/null
159
- _COPROC_PID=""
160
- }
161
-
162
- _coproc_batch_trunc() {
163
- echo "BATCH_TRUNC" >&7
164
- echo "$1" >&7
165
- echo "END" >&7
166
- _COPROC_RESULT=""
167
- local line
168
- while read -r line <&8; do
169
- [[ "$line" == "END" ]] && break
170
- [[ -n "$_COPROC_RESULT" ]] && _COPROC_RESULT+=$'\n'
171
- _COPROC_RESULT+="$line"
172
- done
173
- }
174
-
175
- _coproc_batch_width() {
176
- echo "BATCH_WIDTH" >&7
177
- echo "$1" >&7
178
- echo "END" >&7
179
- _COPROC_RESULT=""
180
- local line
181
- while read -r line <&8; do
182
- [[ "$line" == "END" ]] && break
183
- [[ -n "$_COPROC_RESULT" ]] && _COPROC_RESULT+=$'\n'
184
- _COPROC_RESULT+="$line"
185
- done
186
- }
187
-
188
134
  # Prevent gum from querying terminal colors
189
135
  export CLICOLOR_FORCE=1
190
136
  export COLORTERM=truecolor
@@ -249,1654 +195,17 @@ case "$1" in
249
195
  *) init_viban_json ;;
250
196
  esac
251
197
 
252
- # Colors - Sunset Orange Theme
253
- typeset -A C
254
- C=(
255
- fg "#FFE5D9"
256
- fg_dim "#B89685"
257
- backlog "#8B7B6B"
258
- progress "#FF6B35"
259
- review "#C44536"
260
- card_bg "#2D2416"
261
- card_bd "#5A4A3A"
262
- selected "#FF8C42"
263
- accent "#F7931E"
264
- )
265
-
266
- # 3 statuses only
267
- typeset -A STATUS_LABEL STATUS_COLOR
268
- STATUS_LABEL=(backlog "To-Do" in_progress "In Progress" review "Human Review")
269
- STATUS_COLOR=(backlog "${C[backlog]}" in_progress "${C[progress]}" review "${C[review]}")
270
-
271
- # Priority levels (P0=Critical, P3=Good to have)
272
- typeset -A PRIORITY_LABEL PRIORITY_COLOR
273
- PRIORITY_LABEL=(P0 "CRITICAL" P1 "HIGH" P2 "MEDIUM" P3 "LOW")
274
- 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")
275
-
276
- # Issue types (displayed as tags alongside priority)
277
- typeset -A TYPE_LABEL TYPE_COLOR
278
- TYPE_LABEL=(bug "BUG" feat "FEAT" chore "CHORE" refactor "REFAC")
279
- 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")
280
-
281
- VIBAN_STATUSES=(backlog in_progress review)
282
-
283
- # Pre-generate horizontal borders (cache) - optimized with printf repeat
284
- typeset -A BORDER_CACHE
285
- gen_border() {
286
- local w=$1
287
- [[ -n "${BORDER_CACHE[$w]}" ]] && { echo "${BORDER_CACHE[$w]}"; return; }
288
- # Use printf with dynamic width - single call instead of loop
289
- local b=$(printf '─%.0s' {1..$w})
290
- BORDER_CACHE[$w]="$b"
291
- echo "$b"
292
- }
293
-
294
- # Cached terminal dimensions (with sensible defaults)
295
- CACHED_TERM_W=100
296
- CACHED_TERM_H=30
297
- CACHED_COL_W=32
298
- CACHED_MAX_H=22
299
- CACHED_MAX_TASKS=7
300
-
301
- # Spinner for in_progress cards (Braille dots - consistent 1-char width)
302
- SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
303
- SPINNER_IDX=0
304
-
305
- update_term_cache() {
306
- if [[ -n "$COLUMNS" ]]; then
307
- CACHED_TERM_W=$COLUMNS
308
- elif command -v stty &>/dev/null; then
309
- CACHED_TERM_W=$(stty size 2>/dev/null | cut -d' ' -f2)
310
- else
311
- CACHED_TERM_W=$(tput cols 2>/dev/null || echo 100)
312
- fi
313
- if [[ -n "$LINES" ]]; then
314
- CACHED_TERM_H=$LINES
315
- elif command -v stty &>/dev/null; then
316
- CACHED_TERM_H=$(stty size 2>/dev/null | cut -d' ' -f1)
317
- else
318
- CACHED_TERM_H=$(tput lines 2>/dev/null || echo 30)
319
- fi
320
- CACHED_COL_W=$(( (CACHED_TERM_W - 2) / 3 ))
321
- local _header_extra=0
322
- $VIBAN_IS_GIT_REPO || _header_extra=1
323
- CACHED_MAX_H=$((CACHED_TERM_H - 8 - _header_extra))
324
- CACHED_MAX_TASKS=$((CACHED_MAX_H / 5))
325
- (( CACHED_MAX_TASKS < 2 )) && CACHED_MAX_TASKS=2
326
- (( CACHED_MAX_TASKS > 8 )) && CACHED_MAX_TASKS=8
327
- }
328
-
329
- check_deps() {
330
- command -v gum &>/dev/null || { echo "Error: gum required"; exit 1; }
331
- command -v jq &>/dev/null || { echo "Error: jq required"; exit 1; }
332
- }
333
-
334
- init_json() {
335
- if [[ ! -f "$VIBAN_JSON" ]]; then
336
- local max_wt_id=0
337
- if [[ -d "$VIBAN_DATA_DIR/worktrees" ]]; then
338
- local wt_id
339
- for d in "$VIBAN_DATA_DIR/worktrees/"*(/N); do
340
- wt_id="${d:t}"
341
- [[ "$wt_id" =~ ^[0-9]+$ ]] && (( wt_id > max_wt_id )) && max_wt_id=$wt_id
342
- done
343
- fi
344
- local next_id=$((max_wt_id + 1))
345
- echo "{\"version\":2,\"next_id\":$next_id,\"issues\":[]}" > "$VIBAN_JSON"
346
- elif [[ $(jq '.version // 1' "$VIBAN_JSON") -lt 2 ]]; then
347
- jq '{
348
- version: 2,
349
- next_id: (([.issues[].id] | max // 0) + 1),
350
- issues: .issues
351
- }' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
352
- fi
353
- }
354
-
355
- get_next_id() { jq -r '.next_id // (([.issues[].id] | max // 0) + 1)' "$VIBAN_JSON"; }
356
-
357
- # Display ID: show external_id if present, otherwise #id
358
- display_id() { local id="$1" ext_id="${2:-}"; [[ -n "$ext_id" && "$ext_id" != "null" ]] && echo "$ext_id" || echo "#$id"; }
359
-
360
- # Get external_id for an issue by internal id
361
- get_ext_id() { jq -r --argjson id "$1" '.issues[]|select((.id|tonumber)==$id)|.external_id//""' "$VIBAN_JSON"; }
362
-
363
- # Calculate effective order for sorting (priority-based virtual order for cards without order)
364
- # Used internally for fractional indexing calculations
365
- # Cards with order: use actual order
366
- # Cards without order: P0=1000000, P1=2000000, P2=3000000, P3=4000000 + id
367
- calc_effective_order() {
368
- local order="$1"
369
- local priority="${2:-P3}"
370
- local id="$3"
371
-
372
- if [[ -n "$order" && "$order" != "null" ]]; then
373
- echo "$order"
374
- else
375
- local base_order
376
- case "$priority" in
377
- P0) base_order=1000000;;
378
- P1) base_order=2000000;;
379
- P2) base_order=3000000;;
380
- *) base_order=4000000;;
381
- esac
382
- echo $((base_order + id))
383
- fi
384
- }
385
-
386
- add_issue() {
387
- local title=$(gum input --placeholder "Enter task title..." --width 50 \
388
- --prompt.foreground "${C[accent]}" --cursor.foreground "${C[selected]}")
389
- [[ -z "$title" ]] && return
390
-
391
- # Select type
392
- local issue_type=$(gum choose "bug (BUG)" "feat (FEATURE)" "chore (CHORE)" "refactor (REFACTOR)" \
393
- --header "Select type:" --cursor.foreground "${C[selected]}")
394
- issue_type="${issue_type%% *}" # Extract bug, feat, chore, or refactor
395
- [[ -z "$issue_type" ]] && issue_type="feat"
396
-
397
- # Select priority
398
- local priority=$(gum choose "P0 (CRITICAL)" "P1 (HIGH)" "P2 (MEDIUM)" "P3 (LOW)" \
399
- --header "Select priority:" --cursor.foreground "${C[selected]}")
400
- priority="${priority%% *}" # Extract P0, P1, P2, or P3
401
- [[ -z "$priority" ]] && priority="P3"
402
-
403
- local desc=""
404
- if gum confirm "Add description?" --affirmative "Yes (open editor)" --negative "No" \
405
- --selected.foreground="#000000" --selected.background "${C[accent]}"; then
406
- local tmpfile=$(mktemp)
407
- local editor="${EDITOR:-${VISUAL:-vim}}"
408
- local next_id=$(get_next_id)
409
- local today=$(date +"%Y-%m-%d")
410
- cat > "$tmpfile" <<TEMPLATE
411
- # ─────────────────────────────────────────────
412
- # VIBAN Issue #${next_id}
413
- # ─────────────────────────────────────────────
414
- # Title: $title
415
- # Priority: $priority
416
- # Created: $today
417
- # Status: backlog
418
- # ─────────────────────────────────────────────
419
-
420
- # ▼ Write description below (content below this line will be saved)
421
-
422
- TEMPLATE
423
- $editor "$tmpfile"
424
- desc=$(sed '/^#/d' "$tmpfile" | sed '/./,$!d')
425
- rm -f "$tmpfile"
426
- fi
427
-
428
- local id=$(get_next_id) now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
429
- # New cards don't have order - they follow priority-based sorting
430
- # Order is only assigned when manually moved
431
- local tmpjson=$(mktemp)
432
- printf '%s' "$desc" > "$tmpjson"
433
- jq --arg id "$id" --arg title "$title" --rawfile desc "$tmpjson" --arg priority "$priority" --arg issue_type "$issue_type" --arg now "$now" '
434
- .next_id = ((.next_id // 0) + 1) |
435
- .issues += [{
436
- id:($id|tonumber),
437
- title:$title,
438
- description:$desc,
439
- status:"backlog",
440
- priority:$priority,
441
- type:$issue_type,
442
- assigned_to:null,
443
- created_at:$now,
444
- updated_at:$now
445
- }]' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
446
- rm -f "$tmpjson"
447
- }
448
-
449
- # Sort: backlog/in_progress by effective order, review by updated_at desc
450
- # Effective order: if .order exists -> use it (manually positioned)
451
- # if .order is null -> priority-based virtual order (P0=1M, P1=2M, P2=3M, P3=4M) + id
452
- # This ensures: manually ordered cards stay fixed, others follow priority order
453
- get_issues_by_status() {
454
- local st="$1"
455
- if [[ "$st" == "review" ]]; then
456
- jq -r --arg s "$st" '.issues|map(select(.status==$s))|sort_by(.updated_at)|reverse' "$VIBAN_JSON"
457
- else
458
- jq -r --arg s "$st" '
459
- .issues | map(select(.status==$s)) | sort_by(
460
- if .order != null then [0, .order]
461
- else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id]
462
- end
463
- )
464
- ' "$VIBAN_JSON"
465
- fi
466
- }
467
- count_issues_by_status() { jq -r --arg s "$1" '[.issues[]|select(.status==$s)]|length' "$VIBAN_JSON"; }
468
-
469
- # Get jq sort expression for status (review uses updated_at, others use order/priority)
470
- get_sort_expr() {
471
- local st="$1"
472
- if [[ "$st" == "review" ]]; then
473
- echo 'sort_by(.updated_at) | reverse'
474
- else
475
- echo 'sort_by(if .order != null then [0, .order] else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id] end)'
476
- fi
477
- }
478
-
479
- # Get issue ID by status and card index (uses correct sort order per status)
480
- get_issue_id_at_index() {
481
- local st="$1" idx="$2" json_data="$3"
482
- local sort_expr=$(get_sort_expr "$st")
483
- printf '%s' "$json_data" | jq -r --arg s "$st" --argjson i "$idx" \
484
- ".issues | map(select(.status==\$s)) | $sort_expr | .[\$i].id // empty"
485
- }
486
-
487
- # Find card index by ID after reorder (uses correct sort order per status)
488
- get_card_index_by_id() {
489
- local st="$1" card_id="$2" json_data="$3"
490
- local sort_expr=$(get_sort_expr "$st")
491
- printf '%s' "$json_data" | jq -r --arg s "$st" --argjson id "$card_id" \
492
- ".issues | map(select(.status==\$s)) | $sort_expr | to_entries | map(select(.value.id == \$id)) | .[0].key // 0"
493
- }
494
-
495
- get_term_width() {
496
- # Try multiple methods to get terminal width
497
- if [[ -n "$COLUMNS" ]]; then
498
- echo "$COLUMNS"
499
- elif command -v stty &>/dev/null; then
500
- stty size 2>/dev/null | cut -d' ' -f2
501
- else
502
- tput cols 2>/dev/null || echo 100
503
- fi
504
- }
505
- get_term_height() {
506
- if [[ -n "$LINES" ]]; then
507
- echo "$LINES"
508
- elif command -v stty &>/dev/null; then
509
- stty size 2>/dev/null | cut -d' ' -f1
510
- else
511
- tput lines 2>/dev/null || echo 30
512
- fi
513
- }
514
-
515
-
516
- # ANSI color codes - Orange Theme
517
- A_RESET="\033[0m"
518
- A_BOLD="\033[1m"
519
- A_DIM="\033[2m"
520
- A_FG="\033[38;2;255;229;217m" # Warm cream text
521
- A_GRAY="\033[38;2;139;123;107m" # Warm gray for backlog
522
- A_ORANGE="\033[38;2;255;107;53m" # Vibrant orange for in_progress
523
- A_DEEP_ORANGE="\033[38;2;196;69;54m" # Deep orange for review
524
- A_ACCENT="\033[38;2;247;147;30m" # Golden accent
525
- A_SELECTED="\033[38;2;255;140;66m" # Bright selection
526
-
527
- # Print centered text (uses cached width)
528
- print_center() {
529
- local text=$1 color=${2:-$A_FG}
530
- local w=$CACHED_TERM_W
531
- (( w == 0 )) && w=$(get_term_width)
532
- local len=${#text}
533
- local pad=$(( (w - len) / 2 ))
534
- printf "%${pad}s${color}%s${A_RESET}\033[K\n" "" "$text"
535
- }
536
-
537
- # Draw header with pure ANSI
538
- draw_header() {
539
- printf '\033[K\n'
540
- print_center "VIBAN" "${A_BOLD}${A_ACCENT}"
541
- local _ver repo_name subtitle="Vibe Kanban"
542
- _ver=$(grep '"version"' "$VIBAN_SCRIPT_DIR/package.json" 2>/dev/null | sed 's/.*: *"\([^"]*\)".*/\1/')
543
- repo_name=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)
544
- [[ -n "$repo_name" ]] && subtitle="Vibe Kanban · $repo_name"
545
- if [[ -n "$_ver" ]]; then
546
- subtitle="$subtitle · v$_ver"
547
- # Check for update availability from cache
548
- if [[ -f "$_VIBAN_UPDATE_CACHE" ]]; then
549
- local cached_latest
550
- cached_latest=$(sed -n '2p' "$_VIBAN_UPDATE_CACHE" 2>/dev/null)
551
- if [[ -n "$cached_latest" && "$cached_latest" != "$_ver" ]]; then
552
- local -a cv lv
553
- cv=("${(@s/./)_ver}")
554
- lv=("${(@s/./)cached_latest}")
555
- local _is_newer=false _c _l
556
- for i in 1 2 3; do
557
- _c=${cv[$i]:-0}; _l=${lv[$i]:-0}
558
- _c=${_c%%[^0-9]*}; _l=${_l%%[^0-9]*}
559
- [[ -z "$_c" ]] && _c=0; [[ -z "$_l" ]] && _l=0
560
- if (( _l > _c )); then
561
- _is_newer=true
562
- break
563
- elif (( _l < _c )); then
564
- break
565
- fi
566
- done
567
- $_is_newer && subtitle="$subtitle → v$cached_latest"
568
- fi
569
- fi
570
- fi
571
- print_center "$subtitle" "${A_DIM}"
572
- if ! $VIBAN_IS_GIT_REPO; then
573
- print_center "⚠ Not a git repo · assign/PR unavailable" "${A_DIM}"
574
- fi
575
- printf '\033[K\n'
576
- }
577
-
578
- # Get status color code
579
- get_status_color() {
580
- case "$1" in
581
- backlog) echo "$A_GRAY";;
582
- in_progress) echo "$A_ORANGE";;
583
- review) echo "$A_DEEP_ORANGE";;
584
- esac
585
- }
586
-
587
- # Build column lines into array (optimized - single jq call, cached borders)
588
- # $1: status, $2: col_selected, $3: card_selected (-1 if none), $4: max_h, $5: col_w, $6: json_data
589
- build_column_lines() {
590
- local st="$1"
591
- local is_col_selected="${2:-0}"
592
- local card_sel="${3:--1}"
593
- local max_h="${4:-20}"
594
- local col_w="${5:-30}"
595
- local json_data="$6"
596
- local label="${STATUS_LABEL[$st]:-Unknown}"
597
- local color=$(get_status_color "$st")
598
-
599
- # Single jq call to get all issues for this status (include description, priority, type)
600
- # Replace newlines/tabs in description to prevent parsing issues
601
- # Sort: review by updated_at desc, others by effective order (ordered cards first, then priority)
602
- 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)'
603
- [[ "$st" == "review" ]] && sort_expr='sort_by(.updated_at) | reverse'
604
- local done_ids_tui=$(printf '%s' "$json_data" | jq '[.issues[]|select(.status=="done")|.id]')
605
- local issues_data=$(printf '%s' "$json_data" | jq -r --arg s "$st" --argjson done "$done_ids_tui" "
606
- .issues | map(select(.status==\$s)) | $sort_expr |
607
- .[] | \"\\(.id)\t\\(.title)\t\\((.description // \"\") | gsub(\"[\\n\\t\\r]\"; \" \"))\t\\(.priority // \"P3\")\t\\(.type // \"\")\t\\(.external_id // \"\")\t\\(if ((.blocked_by // []) | length > 0 and any(. as \$b | \$done | index(\$b) == null)) then \"blocked\" else \"\" end)\"")
608
- local count=0
609
- # Count total issues (not capped) for overflow indicator
610
- if [[ -n "$issues_data" ]]; then
611
- local -a _count_arr=("${(f)issues_data}")
612
- count=${#_count_arr[@]}
613
- fi
614
-
615
- # Header centered in column
616
- local hdr_text="● $label"
617
- local hdr_w=$((${#label} + 2))
618
- local left_pad=$(( (col_w - hdr_w) / 2 ))
619
- local right_pad=$((col_w - hdr_w - left_pad))
620
- if (( is_col_selected )); then
621
- printf "%${left_pad}s${A_BOLD}${A_SELECTED}%s${A_RESET}%${right_pad}s\n" "" "$hdr_text" ""
622
- # Underline for selected column - use printf repeat pattern
623
- local underline=$(printf '─%.0s' {1..$hdr_w})
624
- printf "%${left_pad}s${A_SELECTED}%s${A_RESET}%${right_pad}s\n" "" "$underline" ""
625
- else
626
- printf "%${left_pad}s${color}%s${A_RESET}%${right_pad}s\n" "" "$hdr_text" ""
627
- # Empty line for non-selected columns
628
- printf "%${col_w}s\n" ""
629
- fi
630
-
631
- local lines_used=2
632
- local card_inner=$((col_w - 4))
633
- local border=$(gen_border $card_inner)
634
-
635
- # --- Pass 1: Collect card data into arrays ---
636
- local -a _ids _titles _descs _priorities _types _ext_ids _title_max_ws _title_pfxs
637
- local _has_nonascii=0
638
- local _desc_max_w=$((card_inner - 4))
639
- local _spinner_w=0
640
- [[ "$st" == "in_progress" ]] && _spinner_w=2
641
- local _cc _bc _pfx _did
642
-
643
- local -a _blocked_flags=()
644
- while IFS=$'\t' read -r _id _title _desc _priority _type _ext_id _blocked; do
645
- [[ -z "$_id" ]] && continue
646
- (( ${#_ids} >= CACHED_MAX_TASKS )) && break
647
- [[ -z "$_priority" || "$_priority" == "null" ]] && _priority="P3"
648
- [[ -z "$_type" || "$_type" == "null" ]] && _type=""
649
- [[ "$_desc" == "null" ]] && _desc=""
650
-
651
- _ids+=("$_id"); _titles+=("$_title"); _descs+=("$_desc")
652
- _priorities+=("$_priority"); _types+=("$_type"); _ext_ids+=("$_ext_id")
653
- _blocked_flags+=("$_blocked")
654
-
655
- # Per-card title width limit (use display ID length)
656
- _did=$(display_id "$_id" "$_ext_id")
657
- _title_max_ws+=($((card_inner - 4 - ${#_did} - _spinner_w)))
658
- # Prefix for width calc (X as spinner placeholder - same width 1 as braille chars)
659
- _pfx=" "
660
- (( _spinner_w )) && _pfx=" X "
661
- _title_pfxs+=("${_pfx}${_did} ")
662
-
663
- # Check for non-ASCII
664
- if (( ! _has_nonascii )); then
665
- _cc=${#_title}
666
- LC_ALL=C _bc=${#_title}; unset LC_ALL
667
- (( _bc != _cc )) && _has_nonascii=1
668
- if (( ! _has_nonascii && ${#_desc} > 0 )); then
669
- _cc=${#_desc}; LC_ALL=C _bc=${#_desc}; unset LC_ALL
670
- (( _bc != _cc )) && _has_nonascii=1
671
- fi
672
- fi
673
- done <<< "$issues_data"
674
-
675
- local _n=${#_ids}
676
-
677
- # --- Pass 2: Batch compute truncation + widths (single Python call) ---
678
- local -a _short_titles _title_cws _short_descs _desc_cws
679
-
680
- if (( _n > 0 )); then
681
- if (( _has_nonascii )); then
682
- # Build batch input: 2 lines per card (title, desc)
683
- # Format: max_w<TAB>prefix<TAB>string
684
- local _batch_input=""
685
- for (( _i=1; _i<=_n; _i++ )); do
686
- _batch_input+="${_title_max_ws[$_i]}"$'\t'"${_title_pfxs[$_i]}"$'\t'"${_titles[$_i]}"$'\n'
687
- _batch_input+="${_desc_max_w}"$'\t'" "$'\t'"${_descs[$_i]}"$'\n'
688
- done
689
-
690
- # Single Python call: truncate each string and compute content width
691
- local _batch_output
692
- _coproc_batch_trunc "$_batch_input"
693
- _batch_output="$_COPROC_RESULT"
694
-
695
- local _li=0
696
- while IFS=$'\t' read -r _tr _cw; do
697
- ((_li++))
698
- if (( _li % 2 == 1 )); then
699
- _short_titles+=("$_tr"); _title_cws+=($_cw)
700
- else
701
- _short_descs+=("$_tr"); _desc_cws+=($_cw)
702
- fi
703
- done <<< "$_batch_output"
704
- else
705
- # All-ASCII fast path - no Python needed
706
- local _t _mw _fc _d
707
- for (( _i=1; _i<=_n; _i++ )); do
708
- _t="${_titles[$_i]}" _mw=${_title_max_ws[$_i]}
709
- (( ${#_t} > _mw )) && _t="${_t:0:$_mw}"
710
- _short_titles+=("$_t")
711
- _fc="${_title_pfxs[$_i]}${_t}"
712
- _title_cws+=(${#_fc})
713
-
714
- _d="${_descs[$_i]}"
715
- (( ${#_d} > _desc_max_w )) && _d="${_d:0:$_desc_max_w}"
716
- _short_descs+=("$_d")
717
- _desc_cws+=($((2 + ${#_d})))
718
- done
719
- fi
720
- fi
721
-
722
- # --- Pass 3: Render cards ---
723
- local shown=0
724
- local id priority issue_type ext_id did
725
- local spinner_prefix title_content title_pad
726
- local desc_content desc_pad
727
- local priority_tag priority_color type_tag type_color tags_w tags_pad
728
- local border_color text_color desc_color
729
- for (( _i=1; _i<=_n; _i++ )); do
730
- id="${_ids[$_i]}"
731
- priority="${_priorities[$_i]}"
732
- issue_type="${_types[$_i]}"
733
- ext_id="${_ext_ids[$_i]}"
734
- did=$(display_id "$id" "$ext_id")
735
-
736
- # Title line
737
- spinner_prefix=""
738
- [[ "$st" == "in_progress" ]] && spinner_prefix="${SPINNER_FRAMES[$((SPINNER_IDX % ${#SPINNER_FRAMES[@]} + 1))]} "
739
- title_content=" ${spinner_prefix}${did} ${_short_titles[$_i]}"
740
- title_pad=$((card_inner - ${_title_cws[$_i]}))
741
- (( title_pad < 0 )) && title_pad=0
742
-
743
- # Description line
744
- desc_content=" ${_short_descs[$_i]}"
745
- desc_pad=$((card_inner - ${_desc_cws[$_i]}))
746
- (( desc_pad < 0 )) && desc_pad=0
747
-
748
- # Priority, type, and blocked tags
749
- priority_tag="[$priority]"
750
- priority_color="${PRIORITY_COLOR[$priority]:-$A_DIM}"
751
- type_tag="" type_color="" tags_w=0
752
- local blocked_tag="" blocked_color=""
753
- if [[ -n "$issue_type" ]]; then
754
- type_tag="[${TYPE_LABEL[$issue_type]:-$issue_type}]"
755
- type_color="${TYPE_COLOR[$issue_type]:-$A_DIM}"
756
- tags_w=$((${#priority_tag} + 1 + ${#type_tag}))
757
- else
758
- tags_w=${#priority_tag}
759
- fi
760
- if [[ "${_blocked_flags[$_i]}" == "blocked" ]]; then
761
- blocked_tag=" BLOCKED"
762
- blocked_color="\033[38;2;255;69;58m"
763
- tags_w=$((tags_w + 8))
764
- fi
765
- tags_pad=$((card_inner - tags_w - 2))
766
-
767
- border_color="$A_DIM"
768
- text_color="$A_FG"
769
- desc_color="$A_DIM"
770
- if (( is_col_selected && shown == card_sel )); then
771
- border_color="${A_SELECTED}"
772
- text_color="${A_BOLD}${A_ACCENT}"
773
- desc_color="${A_ACCENT}"
774
- fi
775
-
776
- # 5-line card with priority+type tags on 4th line
777
- printf " ${border_color}╭%s╮${A_RESET} \n" "$border"
778
- printf " ${border_color}│${A_RESET}${text_color}%s${A_RESET}%${title_pad}s${border_color}│${A_RESET} \n" "$title_content" ""
779
- printf " ${border_color}│${A_RESET}${desc_color}%s${A_RESET}%${desc_pad}s${border_color}│${A_RESET} \n" "$desc_content" ""
780
- if [[ -n "$type_tag" ]]; then
781
- printf " ${border_color}│${A_RESET} ${priority_color}%s${A_RESET} ${type_color}%s${A_RESET}${blocked_color}%s${A_RESET}%${tags_pad}s${border_color}│${A_RESET} \n" "$priority_tag" "$type_tag" "$blocked_tag" ""
782
- else
783
- printf " ${border_color}│${A_RESET} ${priority_color}%s${A_RESET}${blocked_color}%s${A_RESET}%${tags_pad}s${border_color}│${A_RESET} \n" "$priority_tag" "$blocked_tag" ""
784
- fi
785
- printf " ${border_color}╰%s╯${A_RESET} \n" "$border"
786
-
787
- ((shown++))
788
- lines_used=$((lines_used + 5))
789
- done
790
-
791
- # Overflow indicator
792
- if (( count > _n )); then
793
- local more_text=" +$((count - _n)) more..."
794
- printf "${A_DIM}%s${A_RESET}%$((col_w - ${#more_text}))s\n" "$more_text" ""
795
- ((lines_used++))
796
- fi
797
-
798
- if (( count == 0 )); then
799
- local no_text=" No tasks"
800
- local no_w=${#no_text}
801
- printf "${A_DIM}%s${A_RESET}%$((col_w - no_w))s\n" "$no_text" ""
802
- ((lines_used++))
803
- fi
804
-
805
- while (( lines_used < max_h )); do
806
- printf "%${col_w}s\n" ""
807
- ((lines_used++))
808
- done
809
- }
810
-
811
- # ESC character for ANSI stripping (defined once at script level)
812
- _ESC=$'\e'
813
-
814
- # Pad line to exact width with spaces
815
- # Optimized: use zsh parameter expansion to strip ANSI codes
816
- pad_to_width() {
817
- local line="$1"
818
- local width="$2"
819
- local precomputed_w="${3:-}"
820
- # Strip ANSI codes: ESC [ followed by numbers/semicolons, ending with letter
821
- local plain="${line//${_ESC}\[[0-9;]#[a-zA-Z]/}"
822
- local display_w
823
- if [[ -n "$precomputed_w" ]]; then
824
- display_w=$precomputed_w
825
- else
826
- local char_count=${#plain} byte_count
827
- LC_ALL=C byte_count=${#plain}
828
- unset LC_ALL
829
- if [[ $byte_count -eq $char_count ]]; then
830
- display_w=$char_count
831
- else
832
- display_w=$(( char_count + (byte_count - char_count) / 2 ))
833
- fi
834
- fi
835
- local pad=$((width - display_w))
836
- printf '%s' "$line"
837
- (( pad > 0 )) && printf "%${pad}s" ""
838
- }
839
-
840
- # Draw the board (optimized - arrays instead of temp files)
841
- # $1: col_sel, $2: card_sel, $3: json_data
842
- draw_board() {
843
- local col_sel=${1:-0}
844
- local card_sel=${2:--1}
845
- local json_data="$3"
846
- local col_w=$CACHED_COL_W
847
- local max_h=$CACHED_MAX_H
848
-
849
- local c1=-1 c2=-1 c3=-1
850
- case $col_sel in
851
- 0) c1=$card_sel;;
852
- 1) c2=$card_sel;;
853
- 2) c3=$card_sel;;
854
- esac
855
-
856
- # Build columns to arrays
857
- local -a col1 col2 col3
858
- col1=("${(@f)$(build_column_lines "backlog" $((col_sel == 0)) $c1 $max_h $col_w "$json_data")}")
859
- col2=("${(@f)$(build_column_lines "in_progress" $((col_sel == 1)) $c2 $max_h $col_w "$json_data")}")
860
- col3=("${(@f)$(build_column_lines "review" $((col_sel == 2)) $c3 $max_h $col_w "$json_data")}")
861
-
862
- # Batch compute display widths for all non-ASCII lines (single Python call)
863
- # Build input: all lines from all 3 columns, ANSI-stripped
864
- local -a all_plains
865
- local -a all_widths
866
- local _needs_python=0
867
- local i _plain _cc _bc
868
- for ((i=1; i<=max_h; i++)); do
869
- for _col_line in "${col1[$i]}" "${col2[$i]}" "${col3[$i]}"; do
870
- _plain="${_col_line//${_ESC}\[[0-9;]#[a-zA-Z]/}"
871
- all_plains+=("$_plain")
872
- _cc=${#_plain}
873
- LC_ALL=C _bc=${#_plain}
874
- unset LC_ALL
875
- if [[ $_bc -eq $_cc ]]; then
876
- all_widths+=($_cc)
877
- else
878
- all_widths+=(-1) # marker: needs Python
879
- _needs_python=1
880
- fi
881
- done
882
- done
883
-
884
- if (( _needs_python )); then
885
- # Single Python call to compute all non-ASCII widths
886
- local _input="" _idx
887
- for ((_idx=1; _idx<=${#all_plains[@]}; _idx++)); do
888
- if [[ ${all_widths[$_idx]} -eq -1 ]]; then
889
- _input+="${all_plains[$_idx]}"$'\n'
890
- fi
891
- done
892
- local -a _py_results
893
- _coproc_batch_width "$_input"
894
- _py_results=("${(@f)_COPROC_RESULT}")
895
- # Map Python results back to width array
896
- local _pi=1
897
- for ((_idx=1; _idx<=${#all_widths[@]}; _idx++)); do
898
- if [[ ${all_widths[$_idx]} -eq -1 ]]; then
899
- all_widths[$_idx]=${_py_results[$_pi]}
900
- ((_pi++))
901
- fi
902
- done
903
- fi
904
-
905
- # Merge line by line using precomputed widths
906
- local _wi=1
907
- for ((i=1; i<=max_h; i++)); do
908
- pad_to_width "${col1[$i]}" $col_w "${all_widths[$_wi]}"
909
- ((_wi++))
910
- printf "${A_DIM}│${A_RESET}"
911
- pad_to_width "${col2[$i]}" $col_w "${all_widths[$_wi]}"
912
- ((_wi++))
913
- printf "${A_DIM}│${A_RESET}"
914
- pad_to_width "${col3[$i]}" $col_w "${all_widths[$_wi]}"
915
- ((_wi++))
916
- printf '\033[K\n'
917
- done
918
- }
919
-
920
- draw_footer() {
921
- printf '\033[K\n'
922
- print_center "←→ Column │ ↑↓ Card │ Shift+↑↓ Reorder │ Shift+←→ Move │ Enter Edit/PR │ ⌫ Del │ A Add │ Q Quit" "${A_DIM}"
923
- }
924
-
925
- read_key() {
926
- local _rk_timeout="${1:-0.5}"
927
- local key result=""
928
- # Timeout for spinner animation refresh (default 0.5s)
929
- read -sk1 -t "$_rk_timeout" key 2>/dev/null || { echo "timeout"; return; }
930
-
931
- if [[ "$key" == $'\e' ]]; then
932
- read -sk1 -t 0.1 c2 2>/dev/null
933
- if [[ "$c2" == "[" ]]; then
934
- read -sk1 -t 0.1 c3 2>/dev/null
935
- case "$c3" in
936
- D) result="left";; C) result="right";;
937
- A) result="up";; B) result="down";;
938
- "1")
939
- # Handle Shift+arrow sequences: ESC[1;2X where X is A/B/C/D
940
- read -sk1 -t 0.1 c4 2>/dev/null
941
- if [[ "$c4" == ";" ]]; then
942
- read -sk1 -t 0.1 c5 2>/dev/null
943
- read -sk1 -t 0.1 c6 2>/dev/null
944
- if [[ "$c5" == "2" ]]; then
945
- case "$c6" in
946
- A) result="shift_up";;
947
- B) result="shift_down";;
948
- C) result="shift_right";;
949
- D) result="shift_left";;
950
- esac
951
- fi
952
- fi
953
- ;;
954
- esac
955
- elif [[ "$c2" == "]" ]]; then
956
- # Drain OSC sequence
957
- while read -sk1 -t 0.01 _ 2>/dev/null; do :; done
958
- fi
959
- elif [[ "$key" == "" || "$key" == $'\n' ]]; then
960
- result="enter"
961
- elif [[ "$key" == $'\x7f' || "$key" == $'\b' ]]; then
962
- result="backspace"
963
- else
964
- case "$key" in
965
- q|Q) result="quit";;
966
- a|A) result="add";;
967
- esac
968
- fi
969
-
970
- echo "$result"
971
- }
972
-
973
- # Move card order up or down within a status column (fractional indexing)
974
- # $1: status, $2: current card index, $3: direction (-1 for up, 1 for down)
975
- # When manually moved, the card gets an order value to pin its position
976
- move_card_order() {
977
- local st="$1"
978
- local cur_idx="$2"
979
- local dir="$3"
980
- local new_idx=$((cur_idx + dir))
981
-
982
- # Get issues in current effective order (ordered cards first, then priority-sorted)
983
- local issues=$(jq -r --arg s "$st" '
984
- .issues | map(select(.status==$s)) | sort_by(
985
- if .order != null then [0, .order]
986
- else [1, ({"P0":0,"P1":1,"P2":2,"P3":3}[.priority // "P3"] // 3), .id]
987
- end
988
- )
989
- ' "$VIBAN_JSON")
990
- local cnt=$(printf '%s' "$issues" | jq 'length')
991
-
992
- # Bounds check
993
- (( new_idx < 0 || new_idx >= cnt )) && return 1
994
-
995
- # Get ID of the card to move
996
- local cur_id=$(printf '%s' "$issues" | jq -r ".[$cur_idx].id")
997
-
998
- # Calculate effective order for a card (use actual order or virtual priority-based order)
999
- get_eff_order() {
1000
- local idx=$1
1001
- local order=$(printf '%s' "$issues" | jq -r ".[$idx].order // \"null\"")
1002
- if [[ "$order" != "null" ]]; then
1003
- echo "$order"
1004
- else
1005
- local priority=$(printf '%s' "$issues" | jq -r ".[$idx].priority // \"P3\"")
1006
- local id=$(printf '%s' "$issues" | jq -r ".[$idx].id")
1007
- case "$priority" in
1008
- P0) echo $((1000000 + id));;
1009
- P1) echo $((2000000 + id));;
1010
- P2) echo $((3000000 + id));;
1011
- *) echo $((4000000 + id));;
1012
- esac
1013
- fi
1014
- }
1015
-
1016
- # Calculate new order using fractional indexing
1017
- # Place card between the target position and its neighbor
1018
- local new_order
1019
- if (( dir < 0 )); then
1020
- # Moving up: place between target and the one above it
1021
- local target_order=$(get_eff_order $new_idx)
1022
- if (( new_idx == 0 )); then
1023
- # Moving to top: use target_order - 1
1024
- new_order=$(echo "$target_order - 1" | bc)
1025
- else
1026
- local above_order=$(get_eff_order $(($new_idx - 1)))
1027
- new_order=$(echo "scale=6; ($above_order + $target_order) / 2" | bc)
1028
- fi
1029
- else
1030
- # Moving down: place between target and the one below it
1031
- local target_order=$(get_eff_order $new_idx)
1032
- if (( new_idx == cnt - 1 )); then
1033
- # Moving to bottom: use target_order + 1
1034
- new_order=$(echo "$target_order + 1" | bc)
1035
- else
1036
- local below_order=$(get_eff_order $(($new_idx + 1)))
1037
- new_order=$(echo "scale=6; ($target_order + $below_order) / 2" | bc)
1038
- fi
1039
- fi
1040
-
1041
- # Update the card's order (this pins it to the new position)
1042
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1043
- jq --argjson cur_id "$cur_id" --argjson new_order "$new_order" --arg now "$now" '
1044
- (.issues[] | select(.id==$cur_id)) |= . + {order:$new_order,updated_at:$now}
1045
- ' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1046
-
1047
- return 0
1048
- }
1049
-
1050
- # Get issue ID by status and index (uses correct sort order per status)
1051
- get_issue_id_by_index() {
1052
- local st=$1 idx=$2
1053
- local sort_expr=$(get_sort_expr "$st")
1054
- jq -r --arg s "$st" --argjson i "$idx" ".issues | map(select(.status==\$s)) | $sort_expr | .[\$i].id // empty" "$VIBAN_JSON"
1055
- }
1056
-
1057
- # Delete issue by ID (with worktree cleanup)
1058
- delete_issue() {
1059
- local id=$1
1060
- local repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
1061
- local wt_dir="$VIBAN_DATA_DIR/worktrees/$id"
1062
-
1063
- local branch="issue-$id"
1064
- local _ext_id=$(get_ext_id "$id")
1065
- if [[ -n "$_ext_id" && "$_ext_id" != "null" ]]; then
1066
- local _issue_num="${_ext_id##*:}"
1067
- if git -C "$repo_root" rev-parse --verify "issue-${_issue_num}" &>/dev/null 2>&1; then
1068
- branch="issue-${_issue_num}"
1069
- fi
1070
- fi
1071
-
1072
- if [[ -d "$wt_dir" ]]; then
1073
- git -C "$repo_root" worktree remove "$wt_dir" --force 2>/dev/null
1074
- git -C "$repo_root" branch -D "$branch" 2>/dev/null
1075
- fi
1076
- jq --argjson id "$id" 'del(.issues[]|select((.id|tonumber)==$id))' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && \
1077
- mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1078
- }
1079
-
1080
- level1_columns() {
1081
- IN_TUI=true
1082
- _start_coproc
1083
- local col=0 card=0
1084
-
1085
- # Auto-sync state (120 iterations × 0.5s timeout = ~60s interval)
1086
- local _sync_counter=0
1087
- local _SYNC_INTERVAL=120
1088
- local _sync_pid=""
1089
-
1090
- # Hide cursor and disable input echo
1091
- stty -echo 2>/dev/null
1092
- printf '\033[?25l\033[2J\033[H'
1093
-
1094
- # Initial cache update
1095
- update_term_cache
1096
-
1097
- while true; do
1098
- # Auto-sync: reap finished background sync
1099
- if [[ -n "$_sync_pid" ]]; then
1100
- if ! kill -0 "$_sync_pid" 2>/dev/null; then
1101
- wait "$_sync_pid" 2>/dev/null
1102
- _sync_pid=""
1103
- fi
1104
- fi
1105
-
1106
- # Auto-sync: trigger when interval reached and sync configured
1107
- ((_sync_counter++)) || true
1108
- if (( _sync_counter >= _SYNC_INTERVAL )) && [[ -z "$_sync_pid" && -f "$VIBAN_DATA_DIR/sync.json" ]]; then
1109
- _sync_counter=0
1110
- local _sync_provider
1111
- _sync_provider=$(jq -r '.provider // ""' "$VIBAN_DATA_DIR/sync.json" 2>/dev/null)
1112
- if [[ -n "$_sync_provider" && "$_sync_provider" != "null" ]]; then
1113
- VIBAN_JSON="$VIBAN_JSON" VIBAN_DATA_DIR="$VIBAN_DATA_DIR" \
1114
- VIBAN_PROVIDER="$_sync_provider" VIBAN_SCRIPT_DIR="$VIBAN_SCRIPT_DIR" \
1115
- bash "$VIBAN_SCRIPT_DIR/scripts/sync.sh" --auto &
1116
- _sync_pid=$!
1117
- fi
1118
- fi
1119
- # Cache JSON data once per frame
1120
- local json_data=$(cat "$VIBAN_JSON")
1121
-
1122
- printf '\033[H\033[0m'
1123
- draw_header
1124
- draw_board $col $card "$json_data"
1125
- draw_footer
1126
- printf '\033[J'
1127
-
1128
- # Advance spinner
1129
- ((SPINNER_IDX++))
1130
-
1131
- local st="${VIBAN_STATUSES[$((col + 1))]}"
1132
- # Use cached json_data for count
1133
- local cnt=$(printf '%s' "$json_data" | jq -r --arg s "$st" '[.issues[]|select(.status==$s)]|length')
1134
-
1135
- local key=$(read_key)
1136
- case "$key" in
1137
- left)
1138
- local start_col=$col
1139
- col=$(( (col - 1 + 3) % 3 ))
1140
- # Skip empty columns (but stop if we return to start)
1141
- while (( col != start_col )); do
1142
- local next_st="${VIBAN_STATUSES[$((col + 1))]}"
1143
- local next_cnt=$(printf '%s' "$json_data" | jq -r --arg s "$next_st" '[.issues[]|select(.status==$s)]|length')
1144
- (( next_cnt > 0 )) && break
1145
- col=$(( (col - 1 + 3) % 3 ))
1146
- done
1147
- card=0
1148
- ;;
1149
- right)
1150
- local start_col=$col
1151
- col=$(( (col + 1) % 3 ))
1152
- # Skip empty columns (but stop if we return to start)
1153
- while (( col != start_col )); do
1154
- local next_st="${VIBAN_STATUSES[$((col + 1))]}"
1155
- local next_cnt=$(printf '%s' "$json_data" | jq -r --arg s "$next_st" '[.issues[]|select(.status==$s)]|length')
1156
- (( next_cnt > 0 )) && break
1157
- col=$(( (col + 1) % 3 ))
1158
- done
1159
- card=0
1160
- ;;
1161
- up)
1162
- (( cnt > 0 )) && card=$(( (card - 1 + cnt) % cnt ))
1163
- ;;
1164
- down)
1165
- (( cnt > 0 )) && card=$(( (card + 1) % cnt ))
1166
- ;;
1167
- shift_up)
1168
- if (( cnt > 0 && card > 0 )); then
1169
- local card_id=$(get_issue_id_at_index "$st" "$card" "$json_data")
1170
- if move_card_order "$st" $card -1; then
1171
- local new_json=$(cat "$VIBAN_JSON")
1172
- card=$(get_card_index_by_id "$st" "$card_id" "$new_json")
1173
- fi
1174
- fi
1175
- ;;
1176
- shift_down)
1177
- if (( cnt > 0 && card < cnt - 1 )); then
1178
- local card_id=$(get_issue_id_at_index "$st" "$card" "$json_data")
1179
- if move_card_order "$st" $card 1; then
1180
- local new_json=$(cat "$VIBAN_JSON")
1181
- card=$(get_card_index_by_id "$st" "$card_id" "$new_json")
1182
- fi
1183
- fi
1184
- ;;
1185
- enter)
1186
- if (( cnt > 0 )); then
1187
- local id=$(get_issue_id_at_index "$st" "$card" "$json_data")
1188
- [[ -n "$id" ]] && {
1189
- if [[ "$st" == "review" ]]; then
1190
- # Open associated PR in browser
1191
- local _branch="issue-${id}"
1192
- local _ext_id=$(get_ext_id "$id")
1193
- if [[ -n "$_ext_id" && "$_ext_id" != "null" ]]; then
1194
- local _num="${_ext_id##*:}"
1195
- gh pr view "$_num" --web 2>/dev/null || \
1196
- gh pr list --head "$_branch" --web 2>/dev/null
1197
- else
1198
- gh pr list --head "$_branch" --web 2>/dev/null
1199
- fi
1200
- else
1201
- printf '\033[?25h'
1202
- stty echo 2>/dev/null
1203
- edit_issue "$id"
1204
- stty -echo 2>/dev/null
1205
- printf '\033[?25l\033[2J\033[H'
1206
- fi
1207
- }
1208
- fi
1209
- ;;
1210
- shift_left)
1211
- if (( cnt > 0 && col > 0 )); then
1212
- move_card_status "$st" $card -1 && { col=$((col - 1)); card=0; }
1213
- fi
1214
- ;;
1215
- shift_right)
1216
- if (( cnt > 0 && col < 2 )); then
1217
- move_card_status "$st" $card 1 && { col=$((col + 1)); card=0; }
1218
- fi
1219
- ;;
1220
- add)
1221
- printf '\033[?25h'
1222
- stty echo 2>/dev/null
1223
- add_issue
1224
- stty -echo 2>/dev/null
1225
- printf '\033[?25l\033[2J\033[H'
1226
- ;;
1227
- backspace)
1228
- if (( cnt > 0 )); then
1229
- local id=$(get_issue_id_at_index "$st" "$card" "$json_data")
1230
- [[ -n "$id" ]] && {
1231
- # Move cursor to footer line and run gum there
1232
- printf '\033[?25h'
1233
- stty echo 2>/dev/null
1234
- # Clear footer line and show confirm
1235
- printf '\033[%d;1H\033[K' "$CACHED_TERM_H"
1236
- if gum confirm "Delete $(display_id "$id" "$(get_ext_id "$id")")?" --affirmative "Yes" --negative "No" \
1237
- --selected.foreground="#000000" --selected.background "${C[accent]}"; then
1238
- delete_issue "$id"
1239
- (( card > 0 )) && card=$((card - 1))
1240
- fi
1241
- stty -echo 2>/dev/null
1242
- printf '\033[?25l'
1243
- # Redraw footer only (cursor back to footer)
1244
- printf '\033[%d;1H\033[K' "$((CACHED_TERM_H - 1))"
1245
- draw_footer
1246
- }
1247
- fi
1248
- ;;
1249
- quit)
1250
- printf '\033[?25h\033[0m'
1251
- stty echo 2>/dev/null
1252
- clear
1253
- exit 0
1254
- ;;
1255
- esac
1256
- done
1257
- }
1258
-
1259
- # Edit issue in editor (title + description + priority + type)
1260
- edit_issue() {
1261
- local id=$1
1262
- local issue=$(jq --argjson id "$id" '.issues[]|select((.id|tonumber)==$id)' "$VIBAN_JSON")
1263
- [[ -z "$issue" ]] && return 1
1264
-
1265
- local title=$(printf '%s' "$issue" | jq -r '.title')
1266
- local desc=$(printf '%s' "$issue" | jq -r '.description // ""')
1267
- local ist=$(printf '%s' "$issue" | jq -r '.status')
1268
- local created=$(printf '%s' "$issue" | jq -r '.created_at')
1269
- local priority=$(printf '%s' "$issue" | jq -r '.priority // "P3"')
1270
- local issue_type=$(printf '%s' "$issue" | jq -r '.type // ""')
1271
- local ext_id=$(printf '%s' "$issue" | jq -r '.external_id // ""')
1272
- local did; did=$(display_id "$id" "$ext_id")
1273
-
1274
- local tmpfile=$(mktemp)
1275
- local editor="${EDITOR:-${VISUAL:-vim}}"
1276
-
1277
- cat > "$tmpfile" <<TEMPLATE
1278
- # ─────────────────────────────────────────────
1279
- # VIBAN Issue $did
1280
- # ─────────────────────────────────────────────
1281
- # Status: ${STATUS_LABEL[$ist]}
1282
- # Created: ${created:0:10}
1283
- # ─────────────────────────────────────────────
1284
-
1285
- # ▼ Priority (P0=CRITICAL, P1=HIGH, P2=MEDIUM, P3=LOW)
1286
- $priority
1287
-
1288
- # ▼ Type (bug, feat, chore, refactor) - leave empty for none
1289
- $issue_type
1290
-
1291
- # ▼ Title (single line)
1292
- $title
1293
-
1294
- # ▼ Description (multiple lines allowed)
1295
- $desc
1296
- TEMPLATE
1297
-
1298
- $editor "$tmpfile"
1299
-
1300
- # Parse: priority -> type -> title -> description
1301
- local new_priority="" new_type="" new_title="" new_desc="" parse_stage=0
1302
- while IFS= read -r line; do
1303
- [[ "$line" =~ ^#.*$ ]] && continue
1304
- case $parse_stage in
1305
- 0) # Looking for priority
1306
- [[ -z "$line" ]] && continue
1307
- # Validate priority format (P0-P3)
1308
- if [[ "$line" =~ ^P[0-3]$ ]]; then
1309
- new_priority="$line"
1310
- else
1311
- new_priority="P3" # Default if invalid
1312
- fi
1313
- parse_stage=1
1314
- ;;
1315
- 1) # Looking for type
1316
- [[ -z "$line" ]] && continue # Skip empty lines
1317
- # Validate type format (bug, feat, chore, refactor)
1318
- if [[ "$line" =~ ^(bug|feat|chore|refactor)$ ]]; then
1319
- new_type="$line"
1320
- fi
1321
- parse_stage=2 # Move to stage 2 on any non-empty line
1322
- ;;
1323
- 2) # Looking for title
1324
- [[ -z "$line" ]] && continue
1325
- new_title="$line"
1326
- parse_stage=3
1327
- ;;
1328
- 3) # Collecting description
1329
- # Skip empty lines right after title
1330
- if [[ -z "$new_desc" && -z "$line" ]]; then
1331
- continue
1332
- fi
1333
- new_desc+="$line"$'\n'
1334
- ;;
1335
- esac
1336
- done < "$tmpfile"
1337
-
1338
- # Trim trailing newlines from description
1339
- new_desc="${new_desc%$'\n'}"
1340
-
1341
- rm -f "$tmpfile"
1342
-
1343
- [[ -z "$new_title" ]] && return 1
1344
- [[ -z "$new_priority" ]] && new_priority="P3"
1345
-
1346
- # Update issue
1347
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1348
- local tmpjson=$(mktemp)
1349
- printf '%s' "$new_desc" > "$tmpjson"
1350
- jq --argjson id "$id" --arg title "$new_title" --rawfile desc "$tmpjson" --arg priority "$new_priority" --arg issue_type "$new_type" --arg now "$now" \
1351
- '(.issues[]|select((.id|tonumber)==$id)) |= . + {title:$title,description:$desc,priority:$priority,type:(if $issue_type == "" then null else $issue_type end),updated_at:$now}' \
1352
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1353
- rm -f "$tmpjson"
1354
- }
1355
-
1356
- # Move card to adjacent column (change status)
1357
- move_card_status() {
1358
- local st="$1"
1359
- local card_idx="$2"
1360
- local dir="$3" # -1 for left, 1 for right
1361
-
1362
- local sort_expr=$(get_sort_expr "$st")
1363
- local id=$(jq -r --arg s "$st" --argjson i "$card_idx" \
1364
- ".issues | map(select(.status==\$s)) | $sort_expr | .[\$i].id // empty" "$VIBAN_JSON")
1365
- [[ -z "$id" ]] && return 1
1366
-
1367
- # Find current status index and calculate new status
1368
- local cur_idx=0
1369
- for i in {1..3}; do
1370
- [[ "${VIBAN_STATUSES[$i]}" == "$st" ]] && { cur_idx=$i; break; }
1371
- done
1372
-
1373
- local new_idx=$((cur_idx + dir))
1374
- (( new_idx < 1 || new_idx > 3 )) && return 1
1375
-
1376
- local new_st="${VIBAN_STATUSES[$new_idx]}"
1377
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1378
-
1379
- jq --argjson id "$id" --arg new_st "$new_st" --arg now "$now" \
1380
- '(.issues[]|select((.id|tonumber)==$id)) |= . + {status:$new_st,updated_at:$now}' \
1381
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1382
- }
1383
-
1384
- # CLI commands
1385
- cmd_list() {
1386
- init_json
1387
- local _done_ids=$(jq '[.issues[]|select(.status=="done")|.id]' "$VIBAN_JSON")
1388
- local filter_status=""
1389
- [[ "$1" == "--status" && -n "$2" ]] && filter_status="$2"
1390
-
1391
- echo ""
1392
- if [[ -n "$filter_status" ]]; then
1393
- local count=$(jq -r --arg s "$filter_status" '[.issues[]|select(.status==$s)]|length' "$VIBAN_JSON")
1394
- echo "● $filter_status ($count)"
1395
- jq -r --arg s "$filter_status" --argjson done "$_done_ids" '.issues|map(select(.status==$s))|sort_by(.updated_at)|reverse|.[]|" \(if .external_id then .external_id else "#\(.id)" end) [\(.priority // "P3")]\(if .type then " [\(.type | ascii_upcase)]" else "" end)\(if ((.blocked_by // []) | length > 0 and any(. as $b | $done | index($b) == null)) then " [BLOCKED]" else "" end) \(.title)"' "$VIBAN_JSON"
1396
- echo ""
1397
- else
1398
- for st in $VIBAN_STATUSES; do
1399
- gum style --foreground "${STATUS_COLOR[$st]}" --bold "● ${STATUS_LABEL[$st]} ($(count_issues_by_status "$st"))"
1400
- get_issues_by_status "$st" | jq -r '.[]|" \(if .external_id then .external_id else "#\(.id)" end) [\(.priority // "P3")]\(if .type then " [\(.type | ascii_upcase)]" else "" end) \(.title)"'
1401
- echo ""
1402
- done
1403
- fi
1404
- }
1405
-
1406
- cmd_history() {
1407
- init_json
1408
- local count=$(jq '[.issues[]|select(.status=="done")]|length' "$VIBAN_JSON")
1409
- echo ""
1410
- echo "● Done ($count)"
1411
- jq -r '.issues|map(select(.status=="done"))|sort_by(.updated_at)|reverse|.[]|" \(if .external_id then .external_id else "#\(.id)" end) [\(.priority // "P3")]\(if .type then " [\(.type | ascii_upcase)]" else "" end) \(.title) (\(.updated_at | split("T")[0]))"' "$VIBAN_JSON"
1412
- echo ""
1413
- }
1414
-
1415
- cmd_priority() {
1416
- init_json
1417
- [[ -z "$1" ]] && { echo "Usage: viban priority <id> <P0|P1|P2|P3>"; exit 1; }
1418
- local id="$1"
1419
- local new_priority="${2:-}"
1420
-
1421
- # Validate priority
1422
- if [[ ! "$new_priority" =~ ^P[0-3]$ ]]; then
1423
- echo "Error: Priority must be P0, P1, P2, or P3"
1424
- exit 1
1425
- fi
1426
-
1427
- # Check if issue exists
1428
- local exists=$(jq --argjson id "$id" '[.issues[]|select((.id|tonumber)==$id)]|length' "$VIBAN_JSON")
1429
- [[ "$exists" == "0" ]] && { echo "Error: Issue #$id not found"; exit 1; }
1430
-
1431
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1432
- jq --argjson id "$id" --arg priority "$new_priority" --arg now "$now" \
1433
- '(.issues[]|select((.id|tonumber)==$id)) |= . + {priority:$priority,updated_at:$now}' \
1434
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1435
-
1436
- echo "✓ $(display_id "$id" "$(get_ext_id "$id")") priority → $new_priority"
1437
- }
1438
-
1439
- cmd_add() {
1440
- init_json
1441
- [[ -z "$1" ]] && { echo "Usage: viban add \"title\" [\"description\"] [priority] [type] [attachments...]"; exit 1; }
1442
-
1443
- # Support both positional and named args (--title, --description, --priority, --type, --ext-id)
1444
- local title="" desc="" priority="P3" issue_type="" ext_id="" parent_id=""
1445
- local -a attachments=()
1446
- local positional=()
1447
-
1448
- while [[ $# -gt 0 ]]; do
1449
- case "$1" in
1450
- --title) title="$2"; shift 2 ;;
1451
- --desc|--description) desc="$2"; shift 2 ;;
1452
- --desc-file) [[ -f "$2" ]] && desc="$(cat "$2")"; shift 2 ;;
1453
- --priority) priority="$2"; shift 2 ;;
1454
- --type) issue_type="$2"; shift 2 ;;
1455
- --ext-id|--external-id) ext_id="$2"; shift 2 ;;
1456
- --parent) parent_id="$2"; shift 2 ;;
1457
- --attach|--attachments) shift; while [[ $# -gt 0 && "$1" != --* ]]; do attachments+=("$1"); shift; done ;;
1458
- --*) shift 2 2>/dev/null || shift ;; # skip unknown flags
1459
- *) positional+=("$1"); shift ;;
1460
- esac
1461
- done
1462
-
1463
- # Fall back to positional args if named args not used
1464
- [[ -z "$title" ]] && title="${positional[1]:-}"
1465
- [[ -z "$desc" ]] && desc="${positional[2]:-}"
1466
- [[ "$priority" == "P3" && -n "${positional[3]:-}" ]] && priority="${positional[3]}"
1467
- [[ -z "$issue_type" && -n "${positional[4]:-}" ]] && issue_type="${positional[4]}"
1468
- if [[ ${#attachments[@]} -eq 0 && ${#positional[@]} -gt 4 ]]; then
1469
- attachments=("${positional[@]:5}")
1470
- fi
1471
-
1472
- [[ -z "$title" ]] && { echo "Usage: viban add \"title\" [\"description\"] [priority] [type]"; exit 1; }
1473
-
1474
- # Duplicate detection: warn on similar titles (word-level Jaccard >= 0.5)
1475
- local duplicates=$(jq -r --arg title "$title" '
1476
- def words: ascii_downcase | gsub("[^a-z0-9가-힣\\s]"; " ") | split(" ") | map(select(length > 1)) | unique;
1477
- ($title | words) as $new_words |
1478
- if ($new_words | length) == 0 then empty else
1479
- .issues[] | select(.status != "done") |
1480
- (.title | words) as $existing_words |
1481
- ([$new_words[] | select(. as $w | $existing_words | index($w) != null)] | length) as $overlap |
1482
- ([$new_words[], $existing_words[]] | unique | length) as $union |
1483
- select($union > 0 and ($overlap / $union) >= 0.5) |
1484
- "\(.id)\t\(.title)"
1485
- end
1486
- ' "$VIBAN_JSON")
1487
- if [[ -n "$duplicates" ]]; then
1488
- echo "⚠ Potential duplicate(s):"
1489
- while IFS=$'\t' read -r dup_id dup_title; do
1490
- echo " #$dup_id $dup_title"
1491
- done <<< "$duplicates"
1492
- fi
1493
-
1494
- # Validate parent exists if specified
1495
- if [[ -n "$parent_id" ]]; then
1496
- local parent_exists=$(jq -r --argjson id "$parent_id" '.issues[]|select((.id|tonumber)==$id)|.id//empty' "$VIBAN_JSON")
1497
- [[ -z "$parent_exists" ]] && { echo "Error: Parent issue #$parent_id not found"; exit 1; }
1498
- fi
1499
-
1500
- local id=$(get_next_id) now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1501
- # Validate priority
1502
- [[ ! "$priority" =~ ^P[0-3]$ ]] && priority="P3"
1503
- # Validate type (bug, feat, chore, refactor)
1504
- [[ -n "$issue_type" && ! "$issue_type" =~ ^(bug|feat|chore|refactor)$ ]] && issue_type=""
1505
- # Build attachments JSON array
1506
- local attachments_json="[]"
1507
- if [[ ${#attachments[@]} -gt 0 ]]; then
1508
- attachments_json=$(printf '%s\n' "${attachments[@]}" | jq -R . | jq -s .)
1509
- fi
1510
- # New cards don't have order - they follow priority-based sorting
1511
- # Order is only assigned when manually moved
1512
- local tmpjson=$(mktemp)
1513
- printf '%s' "$desc" > "$tmpjson"
1514
- jq --arg id "$id" --arg title "$title" --rawfile desc "$tmpjson" --arg priority "$priority" --arg issue_type "$issue_type" --arg ext_id "$ext_id" --arg parent "$parent_id" --argjson attachments "$attachments_json" --arg now "$now" '
1515
- .next_id = ((.next_id // 0) + 1) |
1516
- .issues += [{
1517
- id:($id|tonumber),
1518
- title:$title,
1519
- description:$desc,
1520
- status:"backlog",
1521
- priority:$priority,
1522
- type:(if $issue_type == "" then null else $issue_type end),
1523
- external_id:(if $ext_id == "" then null else $ext_id end),
1524
- parent_id:(if $parent == "" then null else ($parent|tonumber) end),
1525
- attachments:$attachments,
1526
- assigned_to:null,
1527
- created_at:$now,
1528
- updated_at:$now
1529
- }]' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1530
- rm -f "$tmpjson"
1531
-
1532
- # Auto-create remote issue if sync is configured and no ext_id provided
1533
- if [[ -z "$ext_id" && -f "$VIBAN_DATA_DIR/sync.json" ]]; then
1534
- local provider
1535
- provider=$(jq -r '.provider // ""' "$VIBAN_DATA_DIR/sync.json" 2>/dev/null)
1536
- if [[ -n "$provider" && "$provider" != "null" ]]; then
1537
- local created_ext_id
1538
- created_ext_id=$(VIBAN_JSON="$VIBAN_JSON" VIBAN_DATA_DIR="$VIBAN_DATA_DIR" \
1539
- VIBAN_PROVIDER="$provider" VIBAN_SCRIPT_DIR="$VIBAN_SCRIPT_DIR" \
1540
- bash "$VIBAN_SCRIPT_DIR/scripts/sync_create.sh" "$id" 2>/dev/null) || true
1541
- [[ -n "$created_ext_id" ]] && ext_id="$created_ext_id"
1542
- fi
1543
- fi
1544
-
1545
- local type_info=""
1546
- [[ -n "$issue_type" ]] && type_info=" [$issue_type]"
1547
- local attach_info=""
1548
- [[ ${#attachments[@]} -gt 0 ]] && attach_info=" +${#attachments[@]} files"
1549
- local parent_info=""
1550
- [[ -n "$parent_id" ]] && parent_info=" (child of #$parent_id)"
1551
- echo "✓ $(display_id "$id" "$ext_id") added ($priority)$type_info$attach_info$parent_info"
1552
- }
1553
-
1554
- cmd_assign() {
1555
- init_json
1556
- local session="${1:-$(echo $RANDOM | md5 | head -c 8)}"
1557
- local done_ids=$(jq '[.issues[]|select(.status=="done")|.id]' "$VIBAN_JSON")
1558
- local issue=$(jq -r --argjson done "$done_ids" '
1559
- .issues|map(select(.status=="backlog"))|map(select(
1560
- (.blocked_by // []) | length == 0 or all(. as $b | $done | index($b) != null)
1561
- ))|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")
1562
- [[ "$issue" == "null" || -z "$issue" ]] && { echo "No backlog"; exit 1; }
1563
- local id=$(printf '%s' "$issue" | jq -r '.id') now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1564
- local ext_id=$(printf '%s' "$issue" | jq -r '.external_id // ""')
1565
-
1566
- # Update status to in_progress (no worktree - use branch workflow)
1567
- jq --argjson id "$id" --arg s "$session" --arg now "$now" \
1568
- '(.issues[]|select((.id|tonumber)==$id)) |= . + {status:"in_progress",assigned_to:$s,updated_at:$now}' \
1569
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1570
-
1571
- # Set iTerm2 session name to issue display ID
1572
- local did; did=$(display_id "$id" "$ext_id")
1573
- printf '\033]1;%s\007' "$did"
1574
-
1575
- echo "✓ $did assigned"
1576
- echo "$id"
1577
- }
1578
-
1579
- cmd_review() {
1580
- init_json
1581
- local id="${1:-$(jq -r '.issues|map(select(.status=="in_progress"))|first|.id//empty' "$VIBAN_JSON")}"
1582
- [[ -z "$id" ]] && { echo "None"; exit 1; }
1583
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1584
- jq --argjson id "$id" --arg now "$now" \
1585
- '(.issues[]|select((.id|tonumber)==$id)) |= . + {status:"review",assigned_to:null,updated_at:$now}' \
1586
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1587
-
1588
- # Clear iTerm2 session name
1589
- printf '\033]1;\007'
1590
-
1591
- echo "✓ $(display_id "$id" "$(get_ext_id "$id")") → review"
1592
- }
1593
-
1594
- cmd_done() {
1595
- init_json
1596
- [[ -z "$1" ]] && { echo "Usage: viban done <id> [--purge]"; exit 1; }
1597
- local id="$1"
1598
- local remove=false
1599
- [[ "$2" == "--remove" || "$2" == "--purge" ]] && remove=true
1600
-
1601
- # Cleanup worktree if exists
1602
- local repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
1603
- local wt_dir="$VIBAN_DATA_DIR/worktrees/$id"
1604
-
1605
- local branch="issue-$id"
1606
- local _ext_id=$(get_ext_id "$id")
1607
- if [[ -n "$_ext_id" && "$_ext_id" != "null" ]]; then
1608
- local _issue_num="${_ext_id##*:}"
1609
- if git -C "$repo_root" rev-parse --verify "issue-${_issue_num}" &>/dev/null 2>&1; then
1610
- branch="issue-${_issue_num}"
1611
- fi
1612
- fi
1613
-
1614
- if [[ -d "$wt_dir" ]]; then
1615
- git -C "$repo_root" worktree remove "$wt_dir" --force 2>/dev/null
1616
- git -C "$repo_root" branch -D "$branch" 2>/dev/null
1617
- echo "✓ worktree removed"
1618
- fi
1619
-
1620
- if $remove; then
1621
- # Delete card (old behavior)
1622
- jq --argjson id "$id" 'del(.issues[]|select((.id|tonumber)==$id))' \
1623
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1624
- printf '\033]1;\007'
1625
- echo "✓ $(display_id "$id" "$(get_ext_id "$id")") completed & removed"
1626
- else
1627
- # Move to done status (non-destructive default)
1628
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1629
- jq --argjson id "$id" --arg now "$now" \
1630
- '(.issues[]|select((.id|tonumber)==$id)) |= . + {status:"done",assigned_to:null,updated_at:$now}' \
1631
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1632
- printf '\033]1;\007'
1633
- echo "✓ $(display_id "$id" "$(get_ext_id "$id")") → done"
1634
- fi
1635
- }
1636
-
1637
- cmd_move() {
1638
- init_json
1639
- [[ -z "$1" || -z "$2" ]] && { echo "Usage: viban move <id> <status>"; exit 1; }
1640
- local id="$1"
1641
- local new_status="$2"
1642
-
1643
- # Validate status
1644
- local valid_statuses="backlog in_progress review done"
1645
- if [[ ! " $valid_statuses " == *" $new_status "* ]]; then
1646
- echo "Error: Invalid status '$new_status'. Valid: backlog, in_progress, review, done"
1647
- exit 1
1648
- fi
1649
-
1650
- # Verify issue exists
1651
- local cur_status=$(jq -r --argjson id "$id" '.issues[]|select((.id|tonumber)==$id)|.status//empty' "$VIBAN_JSON")
1652
- [[ -z "$cur_status" ]] && { echo "Error: Issue #$id not found"; exit 1; }
1653
-
1654
- if [[ "$cur_status" == "$new_status" ]]; then
1655
- echo "Issue #$id is already in $new_status"
1656
- return 0
1657
- fi
1658
-
1659
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1660
- jq --argjson id "$id" --arg s "$new_status" --arg now "$now" \
1661
- '(.issues[]|select((.id|tonumber)==$id)) |= . + {status:$s,updated_at:$now}' \
1662
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1663
-
1664
- echo "✓ $(display_id "$id" "$(get_ext_id "$id")") → $new_status"
1665
- }
1666
-
1667
- cmd_comment() {
1668
- init_json
1669
- [[ -z "$1" || -z "$2" ]] && { echo "Usage: viban comment <id> \"message\""; exit 1; }
1670
- local id="$1"
1671
- shift
1672
- local message="$*"
1673
-
1674
- # Verify issue exists
1675
- local exists=$(jq -r --argjson id "$id" '.issues[]|select((.id|tonumber)==$id)|.id//empty' "$VIBAN_JSON")
1676
- [[ -z "$exists" ]] && { echo "Error: Issue #$id not found"; exit 1; }
1677
-
1678
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1679
- jq --argjson id "$id" --arg msg "$message" --arg now "$now" \
1680
- '(.issues[]|select((.id|tonumber)==$id)) |= . + {comments:((.comments // []) + [{text:$msg,created_at:$now}]),updated_at:$now}' \
1681
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1682
-
1683
- local count=$(jq -r --argjson id "$id" '.issues[]|select((.id|tonumber)==$id)|.comments|length' "$VIBAN_JSON")
1684
- echo "✓ comment #$count added to $(display_id "$id" "$(get_ext_id "$id")")"
1685
- }
1686
-
1687
- cmd_get() {
1688
- init_json
1689
- local id="$1"
1690
- # Output issue JSON
1691
- jq --argjson id "$id" '.issues[]|select((.id|tonumber)==$id)' "$VIBAN_JSON"
1692
- # Show sub-tasks if any
1693
- local subtasks=$(jq -r --argjson id "$id" '[.issues[]|select(.parent_id==$id)]|length' "$VIBAN_JSON")
1694
- if [[ "$subtasks" -gt 0 ]]; then
1695
- local done_count=$(jq -r --argjson id "$id" '[.issues[]|select(.parent_id==$id and .status=="done")]|length' "$VIBAN_JSON")
1696
- echo ""
1697
- echo "Sub-tasks: $done_count/$subtasks done ($((done_count * 100 / subtasks))%)"
1698
- jq -r --argjson id "$id" '.issues[]|select(.parent_id==$id)|" #\(.id) [\(.status)] \(.title)"' "$VIBAN_JSON"
1699
- fi
1700
- }
1701
-
1702
- cmd_attach() {
1703
- init_json
1704
- [[ -z "$1" || -z "$2" ]] && { echo "Usage: viban attach <id> <file1> [file2...]"; exit 1; }
1705
- local id="$1"
1706
- shift
1707
- local files=("$@")
1708
-
1709
- # Check if issue exists
1710
- local exists=$(jq --argjson id "$id" '[.issues[]|select((.id|tonumber)==$id)]|length' "$VIBAN_JSON")
1711
- [[ "$exists" == "0" ]] && { echo "Error: Issue #$id not found"; exit 1; }
1712
-
1713
- # Build new attachments array
1714
- local new_attachments=$(printf '%s\n' "${files[@]}" | jq -R . | jq -s .)
1715
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1716
-
1717
- # Merge with existing attachments
1718
- jq --argjson id "$id" --argjson new "$new_attachments" --arg now "$now" '
1719
- (.issues[] | select((.id|tonumber)==$id)) |= . + {
1720
- attachments: ((.attachments // []) + $new | unique),
1721
- updated_at: $now
1722
- }
1723
- ' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1724
-
1725
- echo "✓ $(display_id "$id" "$(get_ext_id "$id")"): ${#files[@]} file(s) attached"
1726
- }
1727
-
1728
- cmd_link() {
1729
- init_json
1730
- [[ -z "$1" || "$2" != "blocks" || -z "$3" ]] && { echo "Usage: viban link <id> blocks <id>"; exit 1; }
1731
- local blocker_id="$1" blocked_id="$3"
1732
-
1733
- # Verify both issues exist
1734
- local b1=$(jq -r --argjson id "$blocker_id" '.issues[]|select((.id|tonumber)==$id)|.id//empty' "$VIBAN_JSON")
1735
- local b2=$(jq -r --argjson id "$blocked_id" '.issues[]|select((.id|tonumber)==$id)|.id//empty' "$VIBAN_JSON")
1736
- [[ -z "$b1" ]] && { echo "Error: Issue #$blocker_id not found"; exit 1; }
1737
- [[ -z "$b2" ]] && { echo "Error: Issue #$blocked_id not found"; exit 1; }
1738
- [[ "$blocker_id" == "$blocked_id" ]] && { echo "Error: Cannot block self"; exit 1; }
1739
-
1740
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1741
- jq --argjson bid "$blocked_id" --argjson rid "$blocker_id" --arg now "$now" \
1742
- '(.issues[]|select((.id|tonumber)==$bid)) |= . + {blocked_by:((.blocked_by // []) | if index($rid) then . else . + [$rid] end),updated_at:$now}' \
1743
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1744
-
1745
- echo "✓ #$blocker_id blocks #$blocked_id"
1746
- }
1747
-
1748
- cmd_unlink() {
1749
- init_json
1750
- [[ -z "$1" || "$2" != "blocks" || -z "$3" ]] && { echo "Usage: viban unlink <id> blocks <id>"; exit 1; }
1751
- local blocker_id="$1" blocked_id="$3"
1752
-
1753
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1754
- jq --argjson bid "$blocked_id" --argjson rid "$blocker_id" --arg now "$now" \
1755
- '(.issues[]|select((.id|tonumber)==$bid)) |= . + {blocked_by:((.blocked_by // []) - [$rid]),updated_at:$now}' \
1756
- "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1757
-
1758
- echo "✓ #$blocker_id no longer blocks #$blocked_id"
1759
- }
1760
-
1761
- cmd_stats() {
1762
- init_json
1763
- local now_epoch=$(date +%s)
1764
- local week_ago_epoch=$((now_epoch - 604800))
1765
-
1766
- # Total by status
1767
- echo ""
1768
- echo "Board Summary"
1769
- echo "─────────────"
1770
- local backlog_n=$(jq '[.issues[]|select(.status=="backlog")]|length' "$VIBAN_JSON")
1771
- local wip_n=$(jq '[.issues[]|select(.status=="in_progress")]|length' "$VIBAN_JSON")
1772
- local review_n=$(jq '[.issues[]|select(.status=="review")]|length' "$VIBAN_JSON")
1773
- local done_n=$(jq '[.issues[]|select(.status=="done")]|length' "$VIBAN_JSON")
1774
- local total_n=$(jq '.issues|length' "$VIBAN_JSON")
1775
- echo " Backlog: $backlog_n In Progress: $wip_n Review: $review_n Done: $done_n Total: $total_n"
1776
-
1777
- # P0/P1 open count
1778
- local p0_n=$(jq '[.issues[]|select(.status!="done" and .priority=="P0")]|length' "$VIBAN_JSON")
1779
- local p1_n=$(jq '[.issues[]|select(.status!="done" and .priority=="P1")]|length' "$VIBAN_JSON")
1780
- echo " Open P0: $p0_n Open P1: $p1_n"
1781
-
1782
- # Issues added/closed this week
1783
- echo ""
1784
- echo "This Week (last 7 days)"
1785
- echo "───────────────────────"
1786
- local week_ago_iso=$(date -u -r $week_ago_epoch +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d "@$week_ago_epoch" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null)
1787
- local added_week=$(jq -r --arg since "$week_ago_iso" '[.issues[]|select(.created_at >= $since)]|length' "$VIBAN_JSON")
1788
- local closed_week=$(jq -r --arg since "$week_ago_iso" '[.issues[]|select(.status=="done" and .updated_at >= $since)]|length' "$VIBAN_JSON")
1789
- echo " Added: $added_week Completed: $closed_week"
1790
-
1791
- # Average cycle time (created_at → updated_at for done issues)
1792
- echo ""
1793
- echo "Cycle Time"
1794
- echo "──────────"
1795
- local avg_hours=$(jq -r '
1796
- [.issues[]|select(.status=="done")|
1797
- ((.updated_at|split("T")[0]|split("-")|map(tonumber)) as [$y2,$m2,$d2] |
1798
- (.created_at|split("T")[0]|split("-")|map(tonumber)) as [$y1,$m1,$d1] |
1799
- (($y2-$y1)*365 + ($m2-$m1)*30 + ($d2-$d1)) * 24)
1800
- ] | if length == 0 then null else (add / length | floor) end
1801
- ' "$VIBAN_JSON")
1802
- if [[ "$avg_hours" == "null" || -z "$avg_hours" ]]; then
1803
- echo " Average: no completed issues"
1804
- elif [[ "$avg_hours" -lt 24 ]]; then
1805
- echo " Average: <1 day"
1806
- else
1807
- echo " Average: $((avg_hours / 24)) days"
1808
- fi
1809
-
1810
- # Oldest open issue
1811
- echo ""
1812
- echo "Oldest Open Issue"
1813
- echo "─────────────────"
1814
- local oldest=$(jq -r '[.issues[]|select(.status!="done")]|sort_by(.created_at)|first|if . then "#\(.id) [\(.priority)] \(.title) (created \(.created_at|split("T")[0]))" else "none" end' "$VIBAN_JSON")
1815
- echo " $oldest"
1816
- echo ""
1817
- }
1818
-
1819
- cmd_migrate() {
1820
- init_json
1821
- echo "Migrating issues..."
1822
- local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1823
-
1824
- # Migration 1: extract [BUG], [FEATURE], [REFACTOR] from title to type field
1825
- # Also strip [P0-P3] from title if present (already in priority field)
1826
- echo " - Extracting type from titles..."
1827
- jq --arg now "$now" '
1828
- .issues = [.issues[] |
1829
- # Extract type from title
1830
- (if (.title | test("^\\[BUG\\]"; "i")) then "bug"
1831
- elif (.title | test("^\\[FEATURE\\]"; "i")) then "feat"
1832
- elif (.title | test("^\\[FEAT\\]"; "i")) then "feat"
1833
- elif (.title | test("^\\[REFACTOR\\]"; "i")) then "refactor"
1834
- elif (.title | test("^\\[CHORE\\]"; "i")) then "chore"
1835
- else .type // null end) as $extracted_type |
1836
-
1837
- # Clean title: remove [BUG], [FEATURE], [REFACTOR], [CHORE], [P0-P3] prefixes
1838
- (.title |
1839
- gsub("^\\[BUG\\]\\s*"; "") |
1840
- gsub("^\\[FEATURE\\]\\s*"; "") |
1841
- gsub("^\\[FEAT\\]\\s*"; "") |
1842
- gsub("^\\[REFACTOR\\]\\s*"; "") |
1843
- gsub("^\\[CHORE\\]\\s*"; "") |
1844
- gsub("^\\[P[0-3]\\]\\s*"; "")
1845
- ) as $clean_title |
1846
-
1847
- # Update issue
1848
- . + {
1849
- title: $clean_title,
1850
- type: (if $extracted_type then $extracted_type else .type end),
1851
- updated_at: $now
1852
- }
1853
- ]
1854
- ' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1855
-
1856
- # Migration 2: Remove order field from all issues
1857
- # New behavior: order is only set when manually moved, otherwise follows priority
1858
- echo " - Removing order field (reset to priority-based sorting)..."
1859
- jq --arg now "$now" '
1860
- .issues = [.issues[] | del(.order) | . + {updated_at: $now}]
1861
- ' "$VIBAN_JSON" > "$VIBAN_JSON.tmp" && mv "$VIBAN_JSON.tmp" "$VIBAN_JSON"
1862
-
1863
- echo "✓ Migration complete"
1864
- echo ""
1865
- echo "Summary:"
1866
- jq -r '
1867
- [.issues[] | select(.type != null)] | group_by(.type) |
1868
- .[] | " \(.[0].type): \(length) issues"
1869
- ' "$VIBAN_JSON"
1870
- echo " (no type): $(jq '[.issues[] | select(.type == null)] | length' "$VIBAN_JSON") issues"
1871
- echo ""
1872
- echo "Issues by priority:"
1873
- jq -r '
1874
- [.issues[] | select(.status != "done")] |
1875
- group_by(.priority // "P3") | sort_by(.[0].priority) |
1876
- .[] | " \(.[0].priority // "P3"): \(length) issues"
1877
- ' "$VIBAN_JSON"
1878
- }
1879
-
1880
- cmd_sync() {
1881
- local provider="${VIBAN_SYNC_PROVIDER:-}"
1882
- # Auto-detect provider from existing sync.json or default to github
1883
- if [[ -z "$provider" && -f "$VIBAN_DATA_DIR/sync.json" ]]; then
1884
- provider=$(jq -r '.provider // "github"' "$VIBAN_DATA_DIR/sync.json")
1885
- fi
1886
- provider="${provider:-github}"
1887
-
1888
- local provider_script="$VIBAN_SCRIPT_DIR/scripts/providers/${provider}.sh"
1889
- if [[ ! -f "$provider_script" ]]; then
1890
- echo "Error: Unknown sync provider '$provider'"
1891
- echo "Available: $(ls "$VIBAN_SCRIPT_DIR/scripts/providers/" 2>/dev/null | sed 's/\.sh$//' | tr '\n' ' ')"
1892
- exit 1
1893
- fi
1894
-
1895
- VIBAN_JSON="$VIBAN_JSON" VIBAN_DATA_DIR="$VIBAN_DATA_DIR" \
1896
- VIBAN_PROVIDER="$provider" VIBAN_SCRIPT_DIR="$VIBAN_SCRIPT_DIR" \
1897
- bash "$VIBAN_SCRIPT_DIR/scripts/sync.sh" "$@"
1898
- }
198
+ # ============================================================
199
+ # Source library modules
200
+ # ============================================================
201
+ source "$VIBAN_SCRIPT_DIR/lib/config.zsh"
202
+ source "$VIBAN_SCRIPT_DIR/lib/helpers.zsh"
203
+ source "$VIBAN_SCRIPT_DIR/lib/tui.zsh"
204
+ source "$VIBAN_SCRIPT_DIR/lib/commands.zsh"
1899
205
 
206
+ # ============================================================
207
+ # Main dispatch
208
+ # ============================================================
1900
209
  main() {
1901
210
  check_deps
1902
211
  init_json
@@ -1907,15 +216,19 @@ main() {
1907
216
  attach) shift; cmd_attach "$@";;
1908
217
  assign) cmd_assign "$2";;
1909
218
  review) cmd_review "$2";;
1910
- done) cmd_done "$2" "$3";;
219
+ done) shift; cmd_done "$@";;
1911
220
  move) cmd_move "$2" "$3";;
1912
221
  get) cmd_get "$2";;
1913
222
  comment) shift; cmd_comment "$@";;
1914
223
  link) cmd_link "$2" "$3" "$4";;
1915
- unlink) cmd_unlink "$2" "$3" "$4";;
224
+ unlink) shift; cmd_unlink "$@";;
1916
225
  edit) [[ -z "$2" ]] && { echo "Usage: viban edit <id>"; exit 1; }; edit_issue "$2";;
1917
226
  priority) cmd_priority "$2" "$3";;
1918
227
  stats) cmd_stats;;
228
+ backup) cmd_backup;;
229
+ restore) shift; cmd_restore "$@";;
230
+ changelog) cmd_changelog "$2";;
231
+ export) cmd_export "$2";;
1919
232
  migrate) cmd_migrate;;
1920
233
  sync) shift; cmd_sync "$@";;
1921
234
  --version|-v)
@@ -1940,7 +253,7 @@ main() {
1940
253
  echo ""
1941
254
  echo " viban TUI"
1942
255
  echo " viban list Show board"
1943
- echo " viban list --status <s> Filter by status"
256
+ echo " viban list [--status <s>] [--priority P0,P1] [--type bug] [--search text]"
1944
257
  echo " viban history Show completed issues"
1945
258
  echo " viban add \"title\" [\"desc\"] [P0-P3] [type] [--parent <id>] Add task"
1946
259
  echo " viban attach <id> <file1> [file2...] Attach files to task"
@@ -1955,6 +268,10 @@ main() {
1955
268
  echo " viban edit <id> Edit task in editor"
1956
269
  echo " viban get <id> Get task details (JSON)"
1957
270
  echo " viban stats Show throughput metrics and statistics"
271
+ echo " viban backup Snapshot viban.json to backups/"
272
+ echo " viban restore [f] List or restore a backup"
273
+ echo " viban changelog [range] Generate changelog from commits"
274
+ echo " viban export [md|html] Export board as markdown or HTML"
1958
275
  echo " viban migrate Migrate: extract type from title"
1959
276
  echo " viban sync Sync with external issue tracker (GitHub, etc.)"
1960
277
  echo " viban update Update to latest version (if available)"