Skip to content

Commit 4bac22c

Browse files
committed
feat(kanban): v1.3.0 — tmux split pane rendering, smart terminal width detection
Board auto-opens in a tmux split pane when tmux is available, giving the display tool a real TTY with proper terminal dimensions. Any keypress closes the pane. Falls back to inline rendering when not in tmux. Replace the 3-step termW() fallback (stdout/COLUMNS/80) with a 5-step detection chain: stdout.columns → $COLUMNS → tmux pane_width → parent TTY via stty → 120 default. Remove the --width flag and Object.defineProperty monkey-patch hack. Closes #10 Co-Authored-By: Magus <magus@madappgang.com> Crafted with agentic harness Magus (https://github.com/MadAppGang/magus)
1 parent 2e4afa4 commit 4bac22c

5 files changed

Lines changed: 111 additions & 29 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,8 +442,8 @@
442442
"ref": "main",
443443
"sha": "af9911e626dd1406295b20c8e4d80f52ff3d8266"
444444
},
445-
"description": "Kanban board view for task management. v1.2.0: Word-wrapped titles, full-width boards, even column distribution. Visual board with 5 columns, task dependencies (cycle-safe), priority indicators, WIP limits. Shares tasks.json with GTD plugin \u2014 works standalone or alongside GTD.",
446-
"version": "1.2.0",
445+
"description": "Kanban board view for task management. v1.3.0: Auto-open board in tmux split pane with real TTY, smart terminal width detection chain (stdout/COLUMNS/tmux/parent-TTY/120), remove --width hack. Visual board with 5 columns, task dependencies (cycle-safe), priority indicators, WIP limits. Shares tasks.json with GTD plugin \u2014 works standalone or alongside GTD.",
446+
"version": "1.3.0",
447447
"author": {
448448
"name": "Jack Rudenko",
449449
"email": "i@madappgang.com",

plugins/kanban/commands/board.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ KANBAN_LIB="${CLAUDE_PLUGIN_ROOT}/hooks/kanban-lib.sh"
2727
bash -c "source \"${KANBAN_LIB}\" && CWD=\"${CWD}\" kanban_init"
2828

2929
# Build display args
30-
DISPLAY_ARGS="board"
30+
DISPLAY_ARGS=""
3131
FILTER_CONTEXT="" # e.g. "@code" or ""
3232
FILTER_PROJECT="" # e.g. "5" (numeric ID) or ""
3333
COMPACT=false # true if --compact
@@ -36,10 +36,11 @@ COMPACT=false # true if --compact
3636
[ -n "$FILTER_PROJECT" ] && DISPLAY_ARGS="$DISPLAY_ARGS --project $FILTER_PROJECT"
3737
[ "$COMPACT" = "true" ] && DISPLAY_ARGS="$DISPLAY_ARGS --compact"
3838

39-
TERM_WIDTH=$(tput cols 2>/dev/null || echo 80)
40-
bun run "${CLAUDE_PLUGIN_ROOT}/tools/kanban-display.ts" $DISPLAY_ARGS --file "$GTD_FILE" --width "$TERM_WIDTH"
39+
bun run "${CLAUDE_PLUGIN_ROOT}/tools/kanban-display.ts" board $DISPLAY_ARGS --file "$GTD_FILE"
4140
```
4241

42+
The display tool automatically opens in a tmux split pane when tmux is available. Any keypress closes the pane.
43+
4344
## After Showing the Board
4445

4546
- Point out any columns that exceed the WIP limit (default 3 tasks in-progress)

plugins/kanban/lib/table.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* Output: print
1515
*/
1616

17+
import { execSync } from "child_process";
18+
1719
// ── ANSI helpers ─────────────────────────────────────────────────────────────
1820

1921
export const S = {
@@ -54,11 +56,64 @@ export function fg(style: string, text: string): string {
5456

5557
// ── Text utilities ────────────────────────────────────────────────────────────
5658

57-
/** Terminal width. Checks stdout, COLUMNS env var, defaults to 80. */
59+
/**
60+
* Terminal width detection chain.
61+
*
62+
* Tries, in order:
63+
* 1. process.stdout.columns (works when stdout is a TTY)
64+
* 2. $COLUMNS env var (set by some shells / wrappers)
65+
* 3. tmux pane_width (most accurate when inside a tmux pane)
66+
* 4. Parent process TTY via stty (works when our own stdout is piped)
67+
* 5. Fallback to 120 (reasonable default for modern terminals)
68+
*
69+
* Every shell-out is wrapped in try/catch so failures fall through silently.
70+
*/
5871
export function termW(): number {
59-
return process.stdout.columns
60-
|| (process.env.COLUMNS ? parseInt(process.env.COLUMNS) : 0)
61-
|| 80;
72+
// 1. Direct stdout columns
73+
if (process.stdout.columns && process.stdout.columns > 0) {
74+
return process.stdout.columns;
75+
}
76+
77+
// 2. $COLUMNS env var
78+
const envCols = process.env.COLUMNS ? parseInt(process.env.COLUMNS, 10) : 0;
79+
if (envCols > 0) return envCols;
80+
81+
// 3. tmux pane width (only when TMUX env is set)
82+
if (process.env.TMUX) {
83+
try {
84+
const w = parseInt(
85+
execSync("tmux display-message -p '#{pane_width}'", { stdio: ["pipe", "pipe", "pipe"] })
86+
.toString()
87+
.trim(),
88+
10,
89+
);
90+
if (w > 0) return w;
91+
} catch { /* not in tmux or tmux unavailable */ }
92+
}
93+
94+
// 4. Parent process TTY via stty
95+
try {
96+
const ppid = process.ppid ?? process.env.PPID;
97+
if (ppid) {
98+
const ttyName = execSync(`ps -o tty= -p ${ppid}`, { stdio: ["pipe", "pipe", "pipe"] })
99+
.toString()
100+
.trim();
101+
if (ttyName && ttyName !== "?" && ttyName !== "??") {
102+
const devPath = ttyName.startsWith("/dev/") ? ttyName : `/dev/${ttyName}`;
103+
const sttyOut = execSync(`stty size < ${devPath}`, {
104+
stdio: ["pipe", "pipe", "pipe"],
105+
shell: "/bin/sh",
106+
})
107+
.toString()
108+
.trim();
109+
const cols = parseInt(sttyOut.split(/\s+/)[1], 10);
110+
if (cols > 0) return cols;
111+
}
112+
}
113+
} catch { /* no parent TTY or stty failed */ }
114+
115+
// 5. Fallback
116+
return 120;
62117
}
63118

64119
/** Remove all ANSI escape codes from text. */

plugins/kanban/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "kanban",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "Kanban board view for task management. Works with the same tasks.json as the GTD plugin. Provides board visualization, task dependencies, status flow, and optional MCP sync.",
55
"author": { "name": "Jack Rudenko", "email": "i@madappgang.com", "company": "MadAppGang" },
66
"license": "MIT",

plugins/kanban/tools/kanban-display.ts

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
* --project <id> Filter board by project ID
2020
* --compact Compact board display
2121
* --mode simple|regular|modern Board rendering mode (default: regular)
22-
* --width <n> Override terminal width
2322
*/
2423

24+
import { execSync } from "child_process";
2525
import {
2626
S,
2727
fg,
@@ -140,7 +140,7 @@ function wordWrap(text: string, maxWidth: number): string[] {
140140
return lines;
141141
}
142142

143-
/** Resolve effective terminal width (respects --width override via COLUMNS env). */
143+
/** Resolve effective terminal width. */
144144
function getTermWidth(): number {
145145
return termW();
146146
}
@@ -892,15 +892,56 @@ function cmdAdded(taskId: string, title: string): void {
892892

893893
// ── CLI Entry Point ──────────────────────────────────────────────────────────
894894

895+
/**
896+
* When running the "board" command without a TTY (e.g. from Claude Code's Bash tool)
897+
* and tmux is available, re-exec ourselves inside a tmux split pane so we get a real
898+
* TTY with proper terminal width. The pane waits for a keypress then closes.
899+
*/
900+
function maybeReopenInTmux(args: string[]): boolean {
901+
if (args[0] !== "board") return false;
902+
if (process.stdout.isTTY) return false;
903+
if (!process.env.TMUX) return false;
904+
905+
// Determine split direction based on current pane count
906+
let paneCount = 1;
907+
try {
908+
paneCount = parseInt(
909+
execSync("tmux list-panes | wc -l", { stdio: ["pipe", "pipe", "pipe"] }).toString().trim(),
910+
10,
911+
) || 1;
912+
} catch { /* default to 1 */ }
913+
914+
const splitDir = paneCount <= 1 ? "-h" : paneCount <= 2 ? "-v" : "-h";
915+
const splitSize = paneCount <= 2 ? "50%" : "40%";
916+
917+
// Re-invoke ourselves with the same args inside a tmux split pane
918+
const script = process.argv[1];
919+
const escapedArgs = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
920+
const cmd = `bun run '${script}' ${escapedArgs}; bash -c 'read -n1 -s -r'`;
921+
922+
try {
923+
const paneId = execSync(
924+
`tmux split-window ${splitDir} -l '${splitSize}' -P -F '#{pane_id}' "${cmd.replace(/"/g, '\\"')}"`,
925+
{ stdio: ["pipe", "pipe", "pipe"] },
926+
).toString().trim();
927+
execSync(`tmux select-pane -t '${paneId}' -T 'kanban-board'`, { stdio: "ignore" });
928+
} catch {
929+
return false; // tmux split failed, fall through to inline rendering
930+
}
931+
return true;
932+
}
933+
895934
function main(): void {
896935
const args = process.argv.slice(2);
897936
const command = args[0];
898937

938+
// If board command in non-TTY tmux context, reopen in a split pane and exit
939+
if (maybeReopenInTmux(args)) return;
940+
899941
let filePath = `${process.cwd()}/.claude/gtd/tasks.json`;
900942
let filterContext: string | undefined;
901943
let filterProject: string | undefined;
902944
let compact = false;
903-
let widthOverride: number | undefined;
904945
let boardMode: "simple" | "regular" | "modern" = "regular";
905946

906947
// Parse global options (non-positional)
@@ -911,7 +952,6 @@ function main(): void {
911952
case "--filter": filterContext = args[++i]; break;
912953
case "--project": filterProject = args[++i]; break;
913954
case "--compact": compact = true; break;
914-
case "--width": widthOverride = parseInt(args[++i]); break;
915955
case "--mode": {
916956
const m = args[++i];
917957
if (m === "simple" || m === "regular" || m === "modern") boardMode = m;
@@ -921,19 +961,6 @@ function main(): void {
921961
}
922962
}
923963

924-
// Override terminal width if specified
925-
if (widthOverride) {
926-
// Override stdout.columns via Object.defineProperty so termW() picks it up
927-
try {
928-
Object.defineProperty(process.stdout, "columns", {
929-
get: () => widthOverride,
930-
configurable: true,
931-
});
932-
} catch {
933-
process.env.COLUMNS = String(widthOverride);
934-
}
935-
}
936-
937964
const store = loadStore(filePath);
938965

939966
switch (command) {
@@ -998,8 +1025,7 @@ Options:
9981025
--filter <@context> Filter board by context
9991026
--project <id> Filter board by project ID
10001027
--compact Compact board display
1001-
--mode simple|regular|modern Board rendering mode (default: regular)
1002-
--width <n> Override terminal width`);
1028+
--mode simple|regular|modern Board rendering mode (default: regular)`);
10031029
break;
10041030
}
10051031
}

0 commit comments

Comments
 (0)