tuiboard 0.11.0 → 0.13.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.
@@ -19,6 +19,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
19
19
  import * as YAML from "js-yaml";
20
20
 
21
21
  import { isBoardFile } from "~/boards/scan";
22
+ import { LAUNCHERS, SHELLS, type Launcher, type Shell } from "~/input/open-session";
22
23
 
23
24
  export interface BoardConfig {
24
25
  /** Path to the .md file, absolute or relative to the config directory. */
@@ -38,20 +39,39 @@ export interface Config {
38
39
  archiveColumn: string;
39
40
  /**
40
41
  * Optional override for "open the selected agent session" (Enter in the
41
- * agents zone). An argv array; the tokens `{cwd}` and `{sessionId}` are
42
- * substituted, then it's spawned directly (no shell). Point it at your own
42
+ * agents zone). An argv array; the tokens `{cwd}`, `{sessionId}` and
43
+ * `{resume}` (the agent's own resume command, e.g. `claude --resume <id>`)
44
+ * are substituted, then it's spawned directly (no shell). Point it at your own
43
45
  * script to launch a custom terminal layout — e.g.
44
46
  * ["pwsh", "-NoProfile", "-File", "C:/.../code-resume.ps1", "{cwd}", "{sessionId}"]
45
- * When unset, tuiboard falls back to opening a tab + `claude --resume <id>`.
47
+ * When unset, tuiboard falls back to opening a tab + the agent's resume command.
46
48
  */
47
49
  resumeCommand?: string[];
50
+ /**
51
+ * Terminal Enter opens sessions in (config `resume_terminal`). `auto`
52
+ * detects it from the environment; set one explicitly when detection
53
+ * guesses wrong. Ignored when `resumeCommand` is set.
54
+ */
55
+ resumeTerminal: "auto" | Launcher;
56
+ /**
57
+ * Shell the resumed session runs in (config `resume_shell`). `auto` = the
58
+ * shell tuiboard was started from (Git Bash / Nushell / PowerShell on
59
+ * Windows, `$SHELL` elsewhere). Ignored when `resumeCommand` is set.
60
+ */
61
+ resumeShell: "auto" | Shell;
62
+ /**
63
+ * Agent status glyphs (config `status_indicators`): `symbols` (default —
64
+ * the same × ◐ ✓ ○ · herdr uses) or `dots` (● ○ ·).
65
+ */
66
+ statusIndicators: "dots" | "symbols";
48
67
  /**
49
68
  * Template for the shell command copied to the clipboard by `c` in the agents
50
69
  * zone — one paste that `cd`s into the session's directory and resumes it.
51
- * The tokens `{cwd}` and `{sessionId}` are substituted. Default:
52
- * cd "{cwd}" && claude --resume {sessionId}
70
+ * The tokens `{cwd}`, `{sessionId}` and `{resume}` (the agent's own resume
71
+ * command, e.g. `claude --resume <id>`) are substituted. Default:
72
+ * cd "{cwd}" && {resume}
53
73
  * `&&` works in bash/zsh/pwsh/cmd; Nushell users may prefer
54
- * cd "{cwd}"; claude --resume {sessionId}
74
+ * cd "{cwd}"; {resume}
55
75
  */
56
76
  copyResumeCommand: string;
57
77
  /**
@@ -118,12 +138,15 @@ export interface CalendarsConfig {
118
138
  * paste. `&&` chains in bash/zsh/pwsh/cmd (Nushell users override with `;`).
119
139
  */
120
140
  export const DEFAULT_COPY_RESUME_COMMAND =
121
- 'cd "{cwd}" && claude --resume {sessionId}';
141
+ 'cd "{cwd}" && {resume}';
122
142
 
123
143
  export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
124
144
  assignees: [],
125
145
  doneColumn: "Done",
126
146
  archiveColumn: "Archive",
147
+ resumeTerminal: "auto",
148
+ resumeShell: "auto",
149
+ statusIndicators: "symbols",
127
150
  copyResumeCommand: DEFAULT_COPY_RESUME_COMMAND,
128
151
  zones: { planner: "on", agenda: "on", agents: "on" },
129
152
  };
@@ -197,6 +220,9 @@ interface RawConfig {
197
220
  done_column: string;
198
221
  archive_column: string;
199
222
  resume_command: string[];
223
+ resume_terminal: string;
224
+ resume_shell: string;
225
+ status_indicators: string;
200
226
  copy_resume_command: string;
201
227
  calendars: {
202
228
  google?: {
@@ -342,6 +368,13 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
342
368
  Array.isArray(raw.resume_command) && raw.resume_command.length > 0
343
369
  ? raw.resume_command.map(String)
344
370
  : undefined,
371
+ resumeTerminal: (LAUNCHERS as readonly string[]).includes(raw.resume_terminal ?? "")
372
+ ? (raw.resume_terminal as Launcher)
373
+ : "auto",
374
+ resumeShell: (SHELLS as readonly string[]).includes(raw.resume_shell ?? "")
375
+ ? (raw.resume_shell as Shell)
376
+ : "auto",
377
+ statusIndicators: raw.status_indicators === "dots" ? "dots" : "symbols",
345
378
  copyResumeCommand:
346
379
  typeof raw.copy_resume_command === "string" &&
347
380
  raw.copy_resume_command.trim().length > 0
@@ -13,7 +13,19 @@
13
13
  * shares the same input contract.
14
14
  */
15
15
 
16
+ import { quitApp } from "~/app-exit";
16
17
  import { isHiddenColumn } from "~/config/loader";
18
+ import {
19
+ LAUNCHER_NAME,
20
+ describePlan,
21
+ detectLauncher,
22
+ planLaunch,
23
+ runLaunchPlan,
24
+ systemLaunchEnv,
25
+ } from "~/input/open-session";
26
+ import { planHerdrFocus, planHerdrResume } from "~/input/herdr-open";
27
+ import { HARNESS, type AgentSession } from "~/store/agents";
28
+ import { herdrBin, herdrPlace } from "~/store/herdr";
17
29
  import { googleTokenCanWrite } from "~/store/calendar";
18
30
  import { isTask } from "~/parser/markdown";
19
31
  import {
@@ -126,7 +138,7 @@ export function handleKey(
126
138
 
127
139
  // Quit
128
140
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
129
- store.dispose().finally(() => process.exit(0));
141
+ void quitApp(() => store.dispose());
130
142
  return;
131
143
  }
132
144
 
@@ -227,6 +239,13 @@ export function handleKey(
227
239
  // Cycle the board filter — affects which open tasks show up in board
228
240
  // columns. Mirrors Python kanban `action_cycle_filter`. Cycle order:
229
241
  // all → today → overdue → tomorrow → followup → all.
242
+ // In the Agents zone `f` filters sessions by harness instead.
243
+ if (key.name === "f" && ui.activeZone === "agents") {
244
+ const next = store.cycleAgentsFilter();
245
+ const label = next === "all" ? "all harnesses" : `${HARNESS[next].code} · ${HARNESS[next].name}`;
246
+ store.flashBanner("info", `Agents: ${label}`);
247
+ return;
248
+ }
230
249
  if (key.name === "f") {
231
250
  const cycle = ["all", "today", "overdue", "tomorrow", "followup"] as const;
232
251
  const idx = cycle.indexOf(ui.filter);
@@ -477,24 +496,31 @@ function handleTimelineZone(
477
496
 
478
497
  function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
479
498
  const ui = store.state.ui;
480
- const sessions = store.agents.sessions();
499
+ const sessions = store.agentSessions();
481
500
  if (key.name === "j" || key.name === "down") {
482
501
  store.setCursor(0, Math.min(sessions.length - 1, ui.row + 1));
483
502
  } else if (key.name === "k" || key.name === "up") {
484
503
  store.setCursor(0, Math.max(0, ui.row - 1));
504
+ } else if (key.name === "H" || (key.name === "h" && key.shift)) {
505
+ // Jump to the session in herdr, or resume it there.
506
+ const target = sessions[ui.row];
507
+ if (target) void openInHerdr(store, target);
485
508
  } else if (key.name === "enter" || key.name === "return") {
486
- // Open (resume) the selected session in a new WezTerm tab.
509
+ // Open (resume) the selected session in a new terminal tab/window —
510
+ // or, when it's already open in herdr, go there instead of a second copy.
487
511
  const target = sessions[ui.row];
488
- if (target) void openSessionInWezterm(store, target.cwd, target.sessionId);
512
+ if (target?.herdr) void openInHerdr(store, target);
513
+ else if (target) void openSession(store, target);
489
514
  } else if (key.name === "c") {
490
515
  // Copy a one-paste "cd + resume" command for the selected session, so you
491
516
  // can drop it into any tab/pane anywhere and land in the right directory
492
- // resuming the right session (no WezTerm dependency, unlike Enter).
517
+ // resuming the right session (works in any terminal, unlike Enter).
493
518
  const target = sessions[ui.row];
494
519
  if (target) {
495
520
  const cmd = store.config.copyResumeCommand
496
521
  .replaceAll("{cwd}", target.cwd)
497
- .replaceAll("{sessionId}", target.sessionId);
522
+ .replaceAll("{sessionId}", target.sessionId)
523
+ .replaceAll("{resume}", target.resumeCommand);
498
524
  copyToClipboard(cmd).then(
499
525
  () => store.flashBanner("info", `📋 Copied resume command (${target.sessionId.slice(0, 8)})`),
500
526
  (err) => store.flashBanner("error", `Copy failed: ${err}`),
@@ -867,33 +893,26 @@ function fmtHm(m: number): string {
867
893
  }
868
894
 
869
895
  /**
870
- * Open (resume) a Claude Code session in a new WezTerm tab.
871
- *
872
- * Two steps:
873
- * 1. `wezterm cli spawn --cwd <cwd>` opens a new tab running your DEFAULT
874
- * shell in the session's directory (prints the new pane id).
875
- * 2. `wezterm cli send-text` types `claude --resume <id>` + Enter into it.
876
- *
877
- * Running it through the interactive shell (rather than `spawn -- claude …`
878
- * directly) means `claude` gets your full shell environment — PATH, env vars,
879
- * any wrapper — which is why the direct form exited 1. And if `claude` still
880
- * errors, you're left at a live prompt that shows it instead of a vanishing
881
- * tab. Failures (not inside WezTerm, `wezterm` off PATH) surface as a banner.
896
+ * Open (resume) an agent session in a new tab/window of the terminal tuiboard
897
+ * runs in — tmux, herdr, WezTerm, Windows Terminal, Ghostty, or the OS default
898
+ * — inside the user's shell (see open-session.ts). Config `resume_terminal` /
899
+ * `resume_shell` force them. When nothing can open it, the resume command lands
900
+ * on the clipboard instead. The outcome is kept for the session's detail modal.
882
901
  */
883
- async function openSessionInWezterm(
884
- store: TuiStore,
885
- cwd: string,
886
- sessionId: string,
887
- ): Promise<void> {
888
- const { spawn, spawnSync } = await import("node:child_process");
902
+ async function openSession(store: TuiStore, session: AgentSession): Promise<void> {
903
+ const { spawn } = await import("node:child_process");
904
+ const { cwd, sessionId } = session;
889
905
 
890
906
  // Custom override (config `resume_command`): an argv array with {cwd} /
891
- // {sessionId} placeholders, spawned directly (no shell). Lets you launch a
892
- // personal terminal layout without baking it into the distributed tool.
907
+ // {sessionId} / {resume} placeholders, spawned directly (no shell). Lets you
908
+ // launch a personal terminal layout without baking it into the distributed tool.
893
909
  const custom = store.config.resumeCommand;
894
910
  if (custom && custom.length > 0) {
895
911
  const argv = custom.map((arg) =>
896
- arg.replaceAll("{cwd}", cwd).replaceAll("{sessionId}", sessionId),
912
+ arg
913
+ .replaceAll("{cwd}", cwd)
914
+ .replaceAll("{sessionId}", sessionId)
915
+ .replaceAll("{resume}", session.resumeCommand),
897
916
  );
898
917
  const [cmd, ...rest] = argv;
899
918
  try {
@@ -909,39 +928,83 @@ async function openSessionInWezterm(
909
928
  store.flashBanner("error", `resume_command failed: ${e.message}`),
910
929
  );
911
930
  child.unref();
912
- store.flashBanner("info", `↗ Opening session (${sessionId.slice(0, 8)})`);
931
+ store.flashBanner("info", `↗ Opening session via resume_command (${sessionId.slice(0, 8)})`);
913
932
  } catch (e) {
914
933
  store.flashBanner("error", `resume_command failed: ${String(e)}`);
915
934
  }
916
935
  return;
917
936
  }
918
937
 
919
- try {
920
- const spawned = spawnSync("wezterm", ["cli", "spawn", "--cwd", cwd], {
921
- encoding: "utf8",
922
- windowsHide: true,
923
- });
924
- if (spawned.error) {
925
- store.flashBanner("error", `WezTerm launch failed: ${spawned.error.message}`);
926
- return;
927
- }
928
- if (spawned.status !== 0) {
929
- store.flashBanner(
930
- "error",
931
- `WezTerm spawn failed: ${(spawned.stderr || "").trim() || `exit ${spawned.status}`}`,
932
- );
933
- return;
934
- }
935
- const paneId = spawned.stdout.trim();
936
- // Type the resume command into the fresh pane (\r submits, like Enter).
937
- spawnSync(
938
- "wezterm",
939
- ["cli", "send-text", "--pane-id", paneId, "--no-paste"],
940
- { input: `claude --resume ${sessionId}\r`, encoding: "utf8", windowsHide: true },
938
+ const launchEnv = systemLaunchEnv();
939
+ const forced = store.config.resumeTerminal;
940
+ const launcher = forced === "auto" ? detectLauncher(launchEnv) : forced;
941
+ const fail = async (why: string) => {
942
+ const copied = await copyToClipboard(session.resumeCommand).then(
943
+ () => true,
944
+ () => false,
941
945
  );
942
- store.flashBanner("info", `↗ Opened session in WezTerm (${sessionId.slice(0, 8)})`);
946
+ const text = copied ? `${why} — resume command copied, paste it in ${cwd}` : why;
947
+ store.setLastLaunch(sessionId, false, text);
948
+ store.flashBanner(copied ? "warn" : "error", text);
949
+ };
950
+
951
+ if (!launcher) {
952
+ await fail("No supported terminal detected (set resume_terminal)");
953
+ return;
954
+ }
955
+ const plan = planLaunch(
956
+ launcher,
957
+ { cwd, resume: session.resumeCommand, shell: store.config.resumeShell },
958
+ launchEnv,
959
+ );
960
+ try {
961
+ await runLaunchPlan(plan);
962
+ const text = `↗ Opened session in ${LAUNCHER_NAME[launcher]} (${sessionId.slice(0, 8)})`;
963
+ store.setLastLaunch(sessionId, true, `${text}\n${describePlan(plan)}`);
964
+ store.flashBanner("info", text);
965
+ } catch (e) {
966
+ const msg = e instanceof Error ? e.message : String(e);
967
+ await fail(`${LAUNCHER_NAME[launcher]} launch failed: ${msg}`);
968
+ }
969
+ }
970
+
971
+ /**
972
+ * `H` in the Agents zone: focus the herdr pane the session is open in, or
973
+ * resume it in herdr — a new tab in the workspace that holds its project,
974
+ * started with `herdr agent start` (see herdr-open.ts).
975
+ */
976
+ async function openInHerdr(store: TuiStore, session: AgentSession): Promise<void> {
977
+ const bin = herdrBin();
978
+ const snap = store.agents.herdr();
979
+ const { sessionId } = session;
980
+ if (!bin || !snap) {
981
+ const text = bin ? "herdr isn't running" : "herdr isn't installed (https://herdr.dev)";
982
+ store.setLastLaunch(sessionId, false, text);
983
+ store.flashBanner("warn", text);
984
+ return;
985
+ }
986
+ let steps;
987
+ let done: string;
988
+ if (session.herdr) {
989
+ steps = planHerdrFocus(bin, session);
990
+ done = `↗ ${herdrPlace(session.herdr)}`;
991
+ } else {
992
+ const plan = planHerdrResume(bin, session, snap);
993
+ steps = plan.steps;
994
+ done =
995
+ plan.where.kind === "new"
996
+ ? `↗ Resumed in a new herdr workspace "${plan.where.label}"`
997
+ : `↗ Resumed in herdr ${plan.where.label}`;
998
+ store.flashBanner("info", `Starting ${HARNESS[session.provider].name} in herdr…`);
999
+ }
1000
+ try {
1001
+ await runLaunchPlan(steps);
1002
+ store.setLastLaunch(sessionId, true, `${done}\n${describePlan(steps)}`);
1003
+ store.flashBanner("info", `${done} (${sessionId.slice(0, 8)})`);
943
1004
  } catch (e) {
944
- store.flashBanner("error", `WezTerm launch failed: ${String(e)}`);
1005
+ const text = `herdr: ${e instanceof Error ? e.message : String(e)}`;
1006
+ store.setLastLaunch(sessionId, false, text);
1007
+ store.flashBanner("error", text);
945
1008
  }
946
1009
  }
947
1010
 
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Open agent sessions inside herdr (the `H` key, and Enter for sessions
3
+ * already open there): focus the pane a session lives in, or resume it in a
4
+ * new tab of the workspace that holds its project, started through
5
+ * `herdr agent start` so herdr tracks it as an agent from the first second.
6
+ *
7
+ * Planning is pure; the steps run through `runLaunchPlan`.
8
+ */
9
+
10
+ import { basename } from "node:path";
11
+
12
+ import type { LaunchStep } from "~/input/open-session";
13
+ import type { AgentSession } from "~/store/agents";
14
+ import { HERDR_AGENT, samePath, type HerdrSnapshot } from "~/store/herdr";
15
+
16
+ /** Focus the herdr pane a session is open in. */
17
+ export function planHerdrFocus(bin: string, session: AgentSession): LaunchStep[] {
18
+ if (!session.herdr) throw new Error("session isn't open in herdr");
19
+ return [{ cmd: bin, args: ["agent", "focus", session.herdr.paneId] }];
20
+ }
21
+
22
+ export type WorkspaceChoice =
23
+ | { kind: "existing"; workspaceId: string; label: string; why: "cwd" | "name" | "focused" }
24
+ | { kind: "new"; label: string };
25
+
26
+ /**
27
+ * Where a session should open: the workspace already holding a pane in its
28
+ * directory, else one named like the directory, else the focused one — or a
29
+ * new workspace named after the directory when herdr has none.
30
+ */
31
+ export function chooseWorkspace(snap: HerdrSnapshot, cwd: string): WorkspaceChoice {
32
+ const label = (id: string) => snap.workspaces.get(id)?.label ?? id;
33
+ const byCwd = snap.panes.find((p) => p.workspaceId && samePath(p.cwd, cwd));
34
+ if (byCwd) return { kind: "existing", workspaceId: byCwd.workspaceId, label: label(byCwd.workspaceId), why: "cwd" };
35
+ const folder = basename(cwd.replaceAll("\\", "/")).toLowerCase();
36
+ for (const [id, w] of snap.workspaces) {
37
+ if (folder && w.label.toLowerCase() === folder) return { kind: "existing", workspaceId: id, label: w.label, why: "name" };
38
+ }
39
+ const focused = snap.focusedWorkspaceId ?? [...snap.workspaces.keys()][0];
40
+ if (focused) return { kind: "existing", workspaceId: focused, label: label(focused), why: "focused" };
41
+ return { kind: "new", label: basename(cwd.replaceAll("\\", "/")) || "agents" };
42
+ }
43
+
44
+ /** Pane id from a `tab create` / `workspace create` response. */
45
+ function rootPaneId(out: string): string | undefined {
46
+ try {
47
+ const r = JSON.parse(out)?.result;
48
+ return r?.root_pane?.pane_id ?? r?.pane?.pane_id ?? r?.tab?.root_pane?.pane_id;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ /** Tab titles: short, single line. */
55
+ function tabLabel(name: string): string {
56
+ const line = name.split("\n")[0]!.trim();
57
+ return line.length > 32 ? `${line.slice(0, 31).trimEnd()}…` : line || "agent";
58
+ }
59
+
60
+ /**
61
+ * herdr agent names: lowercase letter first, then `[a-z0-9_-]`, ≤ 32 chars.
62
+ * Title slug + short session id, so two sessions with the same title differ.
63
+ */
64
+ export function herdrAgentName(title: string, sessionId: string, fallback: string): string {
65
+ const id = sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(-6);
66
+ const slug = title
67
+ .split("\n")[0]!
68
+ .toLowerCase()
69
+ .normalize("NFKD")
70
+ .replace(/[^a-z0-9]+/g, "-")
71
+ .replace(/^[^a-z]+/, "")
72
+ .replace(/-+$/, "")
73
+ .slice(0, 32 - id.length - 1)
74
+ .replace(/-+$/, "");
75
+ return `${slug || fallback}-${id}`.slice(0, 32);
76
+ }
77
+
78
+ /**
79
+ * Resume a session in herdr: new tab (or workspace) in the session's
80
+ * directory, then start the agent there.
81
+ *
82
+ * POSIX: `herdr agent start --kind <agent> -- <resume args>`, so herdr tracks
83
+ * it from the first second. Windows: herdr launches the agent's executable
84
+ * directly, which fails for npm-installed CLIs (codex, opencode, pi are an
85
+ * extensionless sh shim next to `.cmd`/`.ps1` — "not a valid Win32
86
+ * application"), so the resume command is typed into the pane's shell
87
+ * instead (`herdr pane run`), which resolves it through PATHEXT; herdr still
88
+ * detects the agent from the running process.
89
+ */
90
+ export function planHerdrResume(
91
+ bin: string,
92
+ session: AgentSession,
93
+ snap: HerdrSnapshot,
94
+ platform: NodeJS.Platform = process.platform,
95
+ ): { steps: LaunchStep[]; where: WorkspaceChoice } {
96
+ const where = chooseWorkspace(snap, session.cwd);
97
+ const label = tabLabel(session.displayName);
98
+ const create: LaunchStep =
99
+ where.kind === "existing"
100
+ ? {
101
+ cmd: bin,
102
+ args: ["tab", "create", "--workspace", where.workspaceId, "--cwd", session.cwd, "--label", label, "--focus"],
103
+ captureId: rootPaneId,
104
+ undo: { cmd: bin, args: ["pane", "close", "{id}"] },
105
+ }
106
+ : {
107
+ cmd: bin,
108
+ args: ["workspace", "create", "--cwd", session.cwd, "--label", where.label, "--focus"],
109
+ captureId: rootPaneId,
110
+ undo: { cmd: bin, args: ["pane", "close", "{id}"] },
111
+ };
112
+ if (platform === "win32") {
113
+ return {
114
+ steps: [create, { cmd: bin, args: ["pane", "run", "{id}", session.resumeCommand] }],
115
+ where,
116
+ };
117
+ }
118
+ const [, ...agentArgs] = session.resumeArgv;
119
+ const start: LaunchStep = {
120
+ cmd: bin,
121
+ args: [
122
+ "agent", "start", herdrAgentName(session.displayName, session.sessionId, HERDR_AGENT[session.provider]),
123
+ "--kind", HERDR_AGENT[session.provider],
124
+ "--pane", "{id}",
125
+ "--timeout", "30000",
126
+ "--", ...agentArgs,
127
+ ],
128
+ // herdr waits for the agent to be ready (up to the --timeout above).
129
+ timeoutMs: 40_000,
130
+ };
131
+ return { steps: [create, start], where };
132
+ }