kankaku-tui 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -16,24 +16,27 @@ kankaku setup
16
16
  ```
17
17
 
18
18
  On a real terminal, `kankaku setup` opens as a full-screen wizard — one
19
- step at a time, in the same sidebar/panel look as the rest of the app.
20
- `kankaku` with no arguments does the same the very first time (no
21
- `~/.kankaku/tui.json` yet); after that first run it opens straight into
22
- the Dashboard as usual.
19
+ step at a time, in the same header/panel/footer look as the rest of the
20
+ app (there's no sidebar in the wizard itself: the panel's own title
21
+ tracks progress instead, e.g. `Setup · Agents 1/5`, numbering only the
22
+ steps this run will actually show). `kankaku` with no arguments does the
23
+ same the very first time (no `~/.kankaku/tui.json` yet); after that first
24
+ run it opens straight into the Dashboard as usual.
23
25
 
24
26
  The wizard's steps, `enter` to advance and `esc` to go back throughout
25
- (`esc` at the first step quits):
27
+ (`esc` at the first step, Agents, quits):
26
28
 
27
- 1. **Detect** — which agents kankaku found on this machine, and their
28
- current state.
29
- 2. **Agents** — a checklist (`space` toggles): pi, gentle-shell, Claude
30
- Code. Pre-checked means already configured; unchecking a configured
29
+ 1. **Agents** — a checklist (`space` toggles) that also carries detection
30
+ for every agent kankaku knows about: pi, gentle-shell and Claude Code
31
+ each show `configured (<path, shortened with ~>)` or `not configured`;
32
+ pre-checked means already configured, and unchecking a configured
31
33
  agent schedules removing kankaku from it, not just skipping it. Codex
32
- and OpenCode are listed but disabled — no adapter yet.
33
- 3. **Claude Code** — the `kankaku-claude` checkout path (only shown when
34
+ and OpenCode are listed but disabled, showing `no adapter yet` (found,
35
+ but kankaku can't write its config) or `not installed` (not found).
36
+ 2. **Claude Code** — the `kankaku-claude` checkout path (only shown when
34
37
  Claude Code is checked and not already configured), guessed from any
35
38
  existing `statusLine`.
36
- 4. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
39
+ 3. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
37
40
  inline health check, reusing the current credentials as the default),
38
41
  `install locally`, or `skip`. Installing locally looks for a
39
42
  `kankaku-hub` checkout first; if found, its path is prefilled and
@@ -45,14 +48,14 @@ The wizard's steps, `enter` to advance and `esc` to go back throughout
45
48
  wizard shows the exact commands to run in another terminal and a `c`
46
49
  "check again" action, plus `m` to acknowledge you'll install it
47
50
  manually.
48
- 5. **Roots** — the comma-separated project roots, defaulting to the
51
+ 4. **Roots** — the comma-separated project roots, defaulting to the
49
52
  current `tui.json` (or the parent of the current directory the first
50
- time).
51
- 6. **Review** — the plan: one line per change, with the exact file it
53
+ time). See "Configuration" below for how deep each root is searched.
54
+ 5. **Review** — the plan: one line per change, with the exact file it
52
55
  touches. `enter` applies it.
53
- 7. **Apply** — runs each change and shows its result
56
+ 6. **Apply** — runs each change and shows its result
54
57
  (`wrote`/`unchanged`/`removed`/`started`/`error: …`) as it happens.
55
- 8. **Done** — a summary, then `enter` opens the Dashboard in place — no
58
+ 7. **Done** — a summary, then `enter` opens the Dashboard in place — no
56
59
  restart.
57
60
 
58
61
  `kankaku setup --yes` and `kankaku setup --dry-run` stay exactly as
@@ -90,10 +93,23 @@ this repo with `npm run dev`.
90
93
  }
91
94
  ```
92
95
 
93
- Each root is either a project itself (it has its own `.kankaku/worklog.jsonl`)
94
- or a directory containing one or more projects as direct subdirectories.
95
- `~` expands to the home directory. Missing or malformed config falls back
96
- to the current working directory as the only root.
96
+ ### Projects across roots
97
+
98
+ Each root is searched recursively for projects, up to 5 directory levels
99
+ below it by default: a directory is a project once it has its own
100
+ `.kankaku/worklog.jsonl` — including the root itself — and the search
101
+ still continues below it, so a stray worklog in a parent directory (a pi
102
+ session run once in `~/desarrollo`) never hides the projects beneath;
103
+ every directory is listed at most once. Subdirectories are searched one
104
+ level deeper, skipping `node_modules`, `.git` and any hidden
105
+ (dot-prefixed) directory. This lets one root cover a whole workspace, e.g.
106
+ `~/desarrollo` finding every project under `~/desarrollo/<client>/<project>`
107
+ without listing each one. Projects are deduped by real (symlink-resolved)
108
+ path and sorted by name — the directory's basename, or the last two path
109
+ segments joined with `/` when two discovered projects share a basename
110
+ (e.g. `clientA/shared` and `clientB/shared`). `~` expands to the home
111
+ directory. Missing or malformed config falls back to the current working
112
+ directory as the only root.
97
113
 
98
114
  ### Hub credentials (Catalog and Sync)
99
115
 
@@ -1,12 +1,20 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
2
- import { basename, join } from "node:path";
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, join, sep } from "node:path";
3
+ /** How many directory levels below each root {@link discoverProjects} descends into by default. */
4
+ const DEFAULT_MAX_DEPTH = 5;
5
+ const SKIPPED_DIR_NAMES = new Set(["node_modules", ".git"]);
3
6
  function hasWorklog(dir) {
4
7
  return existsSync(join(dir, ".kankaku", "worklog.jsonl"));
5
8
  }
6
- function childDirs(root) {
9
+ /** `node_modules`, `.git`, and any dot-prefixed (hidden) directory are never searched. */
10
+ function isSkipped(name) {
11
+ return name.startsWith(".") || SKIPPED_DIR_NAMES.has(name);
12
+ }
13
+ function childDirs(dir) {
7
14
  try {
8
- return readdirSync(root)
9
- .map((entry) => join(root, entry))
15
+ return readdirSync(dir)
16
+ .filter((name) => !isSkipped(name))
17
+ .map((name) => join(dir, name))
10
18
  .filter((entry) => {
11
19
  try {
12
20
  return statSync(entry).isDirectory();
@@ -21,23 +29,72 @@ function childDirs(root) {
21
29
  }
22
30
  }
23
31
  /**
24
- * Discover projects under `roots`: a root that itself has
25
- * `.kankaku/worklog.jsonl` is a project; otherwise each direct child
26
- * directory with one is a project. Deduped by `dir`, sorted by `name`.
27
- * Never throws on an unreadable or missing root.
32
+ * The real (symlink-resolved) path, used only as the dedup key — the
33
+ * `ProjectRef.dir` returned to callers always stays the path as
34
+ * discovered. Falls back to `dir` itself when it cannot be resolved (e.g.
35
+ * a permission error), so an unreadable directory never throws.
36
+ */
37
+ function realDirPath(dir) {
38
+ try {
39
+ return realpathSync(dir);
40
+ }
41
+ catch {
42
+ return dir;
43
+ }
44
+ }
45
+ /**
46
+ * Walk `dir`, `depth` levels below its root: a directory with
47
+ * `.kankaku/worklog.jsonl` is a project, and the walk still continues
48
+ * below it — a pi session run once in a parent directory (`~/desarrollo`)
49
+ * leaves a worklog there, and that stray file must never hide the real
50
+ * projects beneath (`~/desarrollo/<client>/<project>`). Subdirectories
51
+ * (skipping `node_modules`, `.git` and any hidden directory) are walked
52
+ * one level deeper, up to `maxDepth`.
28
53
  */
29
- export function discoverProjects(roots) {
30
- const byDir = new Map();
54
+ function walk(dir, depth, maxDepth, found) {
55
+ if (hasWorklog(dir))
56
+ found.set(realDirPath(dir), dir);
57
+ if (depth >= maxDepth)
58
+ return;
59
+ for (const child of childDirs(dir)) {
60
+ walk(child, depth + 1, maxDepth, found);
61
+ }
62
+ }
63
+ /** `basename(dir)`, or the last two path segments joined with `/` when that basename is ambiguous (shared by another discovered project). */
64
+ function projectName(dir, duplicateBasenames) {
65
+ const base = basename(dir);
66
+ if (!duplicateBasenames.has(base))
67
+ return base;
68
+ const segments = dir.split(sep).filter((segment) => segment.length > 0);
69
+ return segments.slice(-2).join("/");
70
+ }
71
+ /**
72
+ * Discover projects under `roots`, searching up to `options.maxDepth`
73
+ * directory levels below each root (default {@link DEFAULT_MAX_DEPTH}): a
74
+ * directory with `.kankaku/worklog.jsonl` is a project and is never
75
+ * descended into (so a project nested inside another project is never
76
+ * double-counted); otherwise its subdirectories are searched one level
77
+ * deeper, skipping `node_modules`, `.git` and any hidden (dot-prefixed)
78
+ * directory. Deduped by real (symlink-resolved) path; sorted by `name`,
79
+ * which is the directory basename, or the last two path segments joined
80
+ * with `/` when two discovered projects share a basename. Never throws on
81
+ * an unreadable or missing root or subdirectory — those are silently
82
+ * skipped.
83
+ */
84
+ export function discoverProjects(roots, options = {}) {
85
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
86
+ const found = new Map();
31
87
  for (const root of roots) {
32
- if (hasWorklog(root)) {
33
- byDir.set(root, { name: basename(root), dir: root });
34
- continue;
35
- }
36
- for (const child of childDirs(root)) {
37
- if (hasWorklog(child)) {
38
- byDir.set(child, { name: basename(child), dir: child });
39
- }
40
- }
88
+ walk(root, 0, maxDepth, found);
89
+ }
90
+ const dirs = Array.from(found.values());
91
+ const basenameCounts = new Map();
92
+ for (const dir of dirs) {
93
+ const base = basename(dir);
94
+ basenameCounts.set(base, (basenameCounts.get(base) ?? 0) + 1);
41
95
  }
42
- return Array.from(byDir.values()).sort((a, b) => a.name.localeCompare(b.name));
96
+ const duplicateBasenames = new Set(Array.from(basenameCounts.entries())
97
+ .filter(([, count]) => count > 1)
98
+ .map(([base]) => base));
99
+ return dirs.map((dir) => ({ name: projectName(dir, duplicateBasenames), dir })).sort((a, b) => a.name.localeCompare(b.name));
43
100
  }
package/dist/cli.js CHANGED
@@ -259,7 +259,7 @@ function gatherWizardFacts(deps) {
259
259
  defaultRoots: [dirname(deps.cwd)],
260
260
  path: tuiPath,
261
261
  };
262
- return { agentFacts, hub, roots };
262
+ return { agentFacts, hub, roots, homeDir: deps.homeDir };
263
263
  }
264
264
  /** Runs `adapters/setup/local-hub.ts#installLocalHub` for real, sharing one `ScriptRunner`/timer setup between the wizard's `installLocalHub` action and `apply`'s own `install-local-hub` handling. */
265
265
  async function performLocalHubInstall(checkout, deps) {
@@ -14,6 +14,22 @@ export function guessClaudeCheckout(statusLineCommand) {
14
14
  const match = CLAUDE_STATUS_LINE_RE.exec(statusLineCommand);
15
15
  return match ? match[1] : "";
16
16
  }
17
+ /**
18
+ * Shorten `path` for display by replacing a leading `homeDir` with `~`.
19
+ * Only a real path-boundary match counts (`homeDir` itself, or `homeDir`
20
+ * followed by `/`) — a sibling directory that merely shares the prefix
21
+ * (e.g. `/homework` vs `/home`) is left unchanged. `path` is returned as-is
22
+ * when it doesn't start under `homeDir`.
23
+ */
24
+ export function shortenHome(path, homeDir) {
25
+ if (homeDir === "")
26
+ return path;
27
+ if (path === homeDir)
28
+ return "~";
29
+ if (path.startsWith(`${homeDir}/`))
30
+ return `~${path.slice(homeDir.length)}`;
31
+ return path;
32
+ }
17
33
  function clearError(errors, key) {
18
34
  if (!(key in errors))
19
35
  return errors;
@@ -30,7 +46,7 @@ export function createWizardState(facts) {
30
46
  const agents = detectAgents(facts.agentFacts);
31
47
  const selected = Object.fromEntries(agents.map((agent) => [agent.id, agent.configured]));
32
48
  return {
33
- step: "detect",
49
+ step: "agents",
34
50
  agents,
35
51
  selected,
36
52
  claudeCheckout: guessClaudeCheckout(facts.agentFacts.claudeCode?.statusLineCommand),
@@ -48,8 +64,8 @@ export function createWizardState(facts) {
48
64
  results: [],
49
65
  };
50
66
  }
51
- /** Whether the Claude Code step should be shown: Claude is selected and not already configured. */
52
- function claudeStepNeeded(state) {
67
+ /** Whether the Claude Code step should be shown: Claude is selected and not already configured. Exported so `ui/setup/wizard-screen.tsx` can number only the steps that will actually be shown for this run. */
68
+ export function claudeStepNeeded(state) {
53
69
  const claude = state.agents.find((agent) => agent.id === "claude-code");
54
70
  return state.selected["claude-code"] === true && claude !== undefined && !claude.configured;
55
71
  }
@@ -154,8 +170,6 @@ export function planFromWizard(state, facts) {
154
170
  /** Advance from the current step: validates it, sets `errors` and stays when invalid, otherwise moves on. */
155
171
  export function next(state, facts) {
156
172
  switch (state.step) {
157
- case "detect":
158
- return { ...state, step: "agents", errors: {} };
159
173
  case "agents":
160
174
  return { ...state, step: claudeStepNeeded(state) ? "claude" : "hub", errors: {} };
161
175
  case "claude": {
@@ -186,13 +200,11 @@ export function next(state, facts) {
186
200
  return state;
187
201
  }
188
202
  }
189
- /** Move back to the previous step, mirroring `next`'s claude-step skip. Terminal/first steps (`detect`, `apply`, `done`) are no-ops. */
203
+ /** Move back to the previous step, mirroring `next`'s claude-step skip. Terminal/first steps (`agents`, `apply`, `done`) are no-ops. */
190
204
  export function back(state) {
191
205
  switch (state.step) {
192
- case "detect":
193
- return state;
194
206
  case "agents":
195
- return { ...state, step: "detect", errors: {} };
207
+ return state;
196
208
  case "claude":
197
209
  return { ...state, step: "agents", errors: {} };
198
210
  case "hub":
@@ -214,10 +226,9 @@ export function applyResult(state, result) {
214
226
  export function hintsForStep(step) {
215
227
  const quit = { key: "q", label: "quit" };
216
228
  switch (step) {
217
- case "detect":
218
- return [{ key: "enter", label: "next" }, quit];
219
229
  case "agents":
220
- return [{ key: "space", label: "toggle" }, { key: "↑↓", label: "move" }, { key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
230
+ // The first step: esc quits (handled by the caller), so it isn't hinted as "back" here.
231
+ return [{ key: "space", label: "toggle" }, { key: "↑↓", label: "move" }, { key: "enter", label: "next" }, quit];
221
232
  case "claude":
222
233
  return [{ key: "enter", label: "next" }, { key: "esc", label: "back" }, quit];
223
234
  case "hub":
@@ -1,26 +1,44 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
- import { Box, Text, useInput } from "ink";
4
- import { applyResult, back, createWizardState, hintsForStep, next, planFromWizard, setClaudeCheckout, setHubField, setHubHealth, setHubLocalManual, setHubMode, setLocalCheckout, setRoots, toggleAgent, } from "../../domain/setup-wizard.js";
5
- import { Layout } from "../layout.js";
3
+ import { Box, Text, useInput, useStdout } from "ink";
4
+ import { applyResult, back, claudeStepNeeded, createWizardState, hintsForStep, next, planFromWizard, setClaudeCheckout, setHubField, setHubHealth, setHubLocalManual, setHubMode, setLocalCheckout, setRoots, shortenHome, toggleAgent, } from "../../domain/setup-wizard.js";
5
+ import { HeaderBar } from "../components/header-bar.js";
6
+ import { KeyHints } from "../components/key-hints.js";
6
7
  import { Panel } from "../components/panel.js";
7
8
  import { Table } from "../components/table.js";
8
9
  import { Checklist } from "../components/checklist.js";
9
10
  import { Radio } from "../components/radio.js";
10
11
  import { TextInput } from "../components/text-input.js";
11
12
  import { useTheme } from "../theme.js";
12
- /** The seven steps shown in the sidebar; `apply` has no entry of its own (it is the Review step's own execution, not a step the user navigates to). */
13
- const SIDEBAR_STEPS = ["detect", "agents", "claude", "hub", "roots", "review", "done"];
13
+ /** Fallback terminal height when neither `rows` nor `useStdout().rows` is available (matches `ui/layout.tsx`'s own fallback). */
14
+ const DEFAULT_ROWS = 24;
15
+ /** Rows used by the header line. */
16
+ const HEADER_ROWS = 1;
17
+ /** Rows used by the footer key-hints line. */
18
+ const FOOTER_ROWS = 1;
19
+ /** Panel titles for every step. Used verbatim for `apply`/`done` (never numbered); `agents`/`claude`/`hub`/`roots`/`review` get a `n/total` suffix from {@link panelTitle}. */
14
20
  const STEP_TITLES = {
15
- detect: "Detect",
16
21
  agents: "Agents",
17
- claude: "Claude",
22
+ claude: "Claude Code",
18
23
  hub: "Hub",
19
24
  roots: "Roots",
20
25
  review: "Review",
21
26
  apply: "Apply",
22
27
  done: "Done",
23
28
  };
29
+ /** The steps a run numbers in its panel title, in order; `claude` is dropped from this list when the run doesn't need it (see {@link numberedStepsForRun}). `apply` and `done` are never numbered — `apply` is Review's own execution and `done` is the terminal summary. */
30
+ const NUMBERED_STEPS = ["agents", "claude", "hub", "roots", "review"];
31
+ /** The steps that will actually be shown for this run, e.g. `["agents", "hub", "roots", "review"]` when Claude Code is skipped. */
32
+ function numberedStepsForRun(state) {
33
+ return NUMBERED_STEPS.filter((step) => step !== "claude" || claudeStepNeeded(state));
34
+ }
35
+ /** `[ Setup · Agents 1/5 ]`-style progress title: `n/total` over only the steps this run will show; `apply`/`done` render unnumbered. */
36
+ function panelTitle(state) {
37
+ const title = STEP_TITLES[state.step];
38
+ const steps = numberedStepsForRun(state);
39
+ const index = steps.indexOf(state.step);
40
+ return index === -1 ? `Setup · ${title}` : `Setup · ${title} ${index + 1}/${steps.length}`;
41
+ }
24
42
  const AGENT_TITLES = {
25
43
  pi: "pi",
26
44
  "gentle-shell": "gentle-shell",
@@ -28,45 +46,11 @@ const AGENT_TITLES = {
28
46
  codex: "Codex",
29
47
  opencode: "OpenCode",
30
48
  };
31
- /** The sidebar's own active id: `apply` has no sidebar row, so it maps to `review` (the step it is executing). */
32
- function sidebarActiveId(step) {
33
- return step === "apply" ? "review" : step;
34
- }
35
- function wizardSidebarItems(step) {
36
- const activeIndex = SIDEBAR_STEPS.indexOf(sidebarActiveId(step));
37
- return SIDEBAR_STEPS.map((entry, index) => ({
38
- id: entry,
39
- key: String(index + 1),
40
- label: index < activeIndex ? `✓ ${STEP_TITLES[entry]}` : STEP_TITLES[entry],
41
- }));
42
- }
43
- function agentStateLabel(agent) {
49
+ /** The Agents checklist row's note: presence/configured state for pi/gentle-shell/Claude Code (always checkable), or why an agent with no adapter yet (Codex/OpenCode, always disabled) can't be. */
50
+ function agentNote(agent, homeDir) {
44
51
  if (!agent.adapterAvailable)
45
- return "no adapter";
46
- if (!agent.present)
47
- return "not found";
48
- return agent.configured ? "configured" : "present";
49
- }
50
- const DETECT_FIXED_COLUMNS_WIDTH = 14 + 1 + 12 + 1 + 2 + 2;
51
- function detectColumns(panelWidth) {
52
- const detailWidth = Math.max(panelWidth - DETECT_FIXED_COLUMNS_WIDTH, 10);
53
- return [
54
- { key: "agent", header: "agent", width: 14 },
55
- { key: "state", header: "state", width: 12 },
56
- { key: "detail", header: "detail", width: detailWidth },
57
- ];
58
- }
59
- function detectCell(row, key) {
60
- switch (key) {
61
- case "agent":
62
- return AGENT_TITLES[row.id];
63
- case "state":
64
- return agentStateLabel(row);
65
- case "detail":
66
- return row.detail;
67
- default:
68
- return "";
69
- }
52
+ return agent.present ? "no adapter yet" : "not installed";
53
+ return agent.configured ? `configured (${shortenHome(agent.detail, homeDir)})` : "not configured";
70
54
  }
71
55
  const REVIEW_FIXED_COLUMNS_WIDTH = 30 + 1 + 2 + 2;
72
56
  function reviewColumns(panelWidth) {
@@ -85,17 +69,21 @@ function applyResultLine(result) {
85
69
  const detail = result.detail ? ` (${result.detail})` : "";
86
70
  return `${result.outcome} ${result.action.file}${detail}`;
87
71
  }
88
- function agentChecklistItems(agents, selected) {
89
- return agents.map((agent) => {
90
- const disabled = agent.id === "codex" || agent.id === "opencode";
91
- return {
92
- id: agent.id,
93
- label: AGENT_TITLES[agent.id],
94
- checked: selected[agent.id] === true,
95
- disabled,
96
- note: disabled ? "no adapter yet" : undefined,
97
- };
98
- });
72
+ /**
73
+ * Detection is folded into the Agents checklist itself (R1): each row's
74
+ * label already carries the agent's presence/configured state, e.g.
75
+ * `pi — configured (~/.pi/agent/settings.json)` or `gentle-shell — not
76
+ * configured`; disabled rows (no adapter yet) read `Codex — no adapter
77
+ * yet` or `OpenCode — not installed`. `note` is left unset so `Checklist`
78
+ * never appends its own `(note)` suffix on top.
79
+ */
80
+ function agentChecklistItems(agents, selected, homeDir) {
81
+ return agents.map((agent) => ({
82
+ id: agent.id,
83
+ label: `${AGENT_TITLES[agent.id]} — ${agentNote(agent, homeDir)}`,
84
+ checked: selected[agent.id] === true,
85
+ disabled: !agent.adapterAvailable,
86
+ }));
99
87
  }
100
88
  const HUB_MODE_OPTIONS = [
101
89
  { value: "existing", label: "use an existing hub" },
@@ -112,10 +100,15 @@ function healthLine(healthOk, checking) {
112
100
  return healthOk ? "health ok" : "health failed";
113
101
  }
114
102
  /**
115
- * `kankaku setup`'s interactive wizard: one full-screen step at a time
116
- * inside the shared `Layout` frame, driven by `domain/setup-wizard.ts`'s
117
- * pure reducers. Enter advances (validating first), Esc goes back (quits
118
- * from Detect, via `onQuit`); `q` quits from anywhere a text field isn't
103
+ * `kankaku setup`'s interactive wizard: one full-screen step at a time,
104
+ * driven by `domain/setup-wizard.ts`'s pure reducers. Unlike every other
105
+ * screen it renders its own header/panel/footer frame directly instead of
106
+ * `ui/layout.tsx`'s `Layout` — there is no sidebar to show, since the
107
+ * wizard's progress lives in the panel's own title instead (`panelTitle`,
108
+ * e.g. `Setup · Agents 1/5`, numbering only the steps this run will
109
+ * actually show — Claude Code is dropped when it isn't needed). Enter
110
+ * advances (validating first), Esc goes back (quits from Agents, the
111
+ * first step, via `onQuit`); `q` quits from anywhere a text field isn't
119
112
  * currently capturing keystrokes.
120
113
  */
121
114
  export function SetupWizard({ facts, actions, onDone, onQuit, version, columns, rows }) {
@@ -162,7 +155,7 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
162
155
  return;
163
156
  }
164
157
  if (key.escape) {
165
- if (state.step === "detect") {
158
+ if (state.step === "agents") {
166
159
  onQuit();
167
160
  return;
168
161
  }
@@ -189,10 +182,14 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
189
182
  }
190
183
  }
191
184
  });
192
- const stepTitle = STEP_TITLES[state.step];
193
185
  const hints = hintsForStep(state.step);
194
- return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku setup${version ? ` ${version}` : ""}`, sidebarItems: wizardSidebarItems(state.step), activeId: sidebarActiveId(state.step), sidebarStats: [], keyHints: hints, focus: "main", children: ({ mainWidth, mainHeight }) => (_jsxs(Panel, { title: `Setup · ${stepTitle}`, width: mainWidth, height: mainHeight, active: true, children: [state.step === "detect" && (_jsx(Table, { columns: detectColumns(mainWidth), rows: state.agents, rowKey: (row) => row.id, cell: detectCell, emptyText: "no agents detected" })), state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected), cursor: agentCursor, onToggle: (id) => setState((s) => toggleAgent(s, id)), onMove: (delta) => setAgentCursor((c) => Math.min(Math.max(c + delta, 0), state.agents.length - 1)), focused: true })), state.step === "claude" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "kankaku-claude checkout path:" }), _jsx(TextInput, { value: state.claudeCheckout, onChange: (value) => setState((s) => setClaudeCheckout(s, value)), focused: true }), state.errors.claudeCheckout && _jsx(Text, { color: theme.error, children: state.errors.claudeCheckout })] })), state.step === "hub" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Radio, { options: HUB_MODE_OPTIONS, value: state.hub.mode, onChange: (mode) => setState((s) => setHubMode(s, mode)), focused: hubFocus === 0 }), state.hub.mode === "existing" && (_jsxs(Box, { flexDirection: "column", children: [["url", "email", "password"].map((field, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: `${field}: ` }), _jsx(TextInput, { value: state.hub[field], onChange: (value) => setState((s) => setHubField(s, field, value)), masked: field === "password", focused: hubFocus === index + 1 })] }, field))), state.errors.url && _jsx(Text, { color: theme.error, children: state.errors.url }), state.errors.email && _jsx(Text, { color: theme.error, children: state.errors.email }), state.errors.password && _jsx(Text, { color: theme.error, children: state.errors.password }), _jsx(Text, { children: `[ check ] c ${healthLine(state.hub.healthOk, healthChecking)}` })] })), state.hub.mode === "local" && hasCheckoutField && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "local checkout path:" }), _jsx(TextInput, { value: checkoutValue, onChange: (value) => setState((s) => setLocalCheckout(s, value)), focused: hubFocus === 1 })] })), state.hub.mode === "local" && !hasCheckoutField && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no kankaku-hub checkout found; run these commands, then check again:" }), actions.manualCommands(state.hub.localCheckout || undefined).map((line) => (_jsx(Text, { children: ` ${line}` }, line))), _jsx(Text, { children: `[ check again ] c ${healthLine(state.hub.healthOk, healthChecking)}` }), _jsx(Text, { children: `[ m ] acknowledge manual install${state.hub.localManual ? " (acknowledged)" : ""}` }), state.errors.localCheckout && _jsx(Text, { color: theme.error, children: state.errors.localCheckout })] }))] })), state.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
195
- setRootsText(value);
196
- setState((s) => setRoots(s, value));
197
- }, focused: true }), state.errors.roots && _jsx(Text, { color: theme.error, children: state.errors.roots })] })), state.step === "review" && (_jsx(Table, { columns: reviewColumns(mainWidth), rows: state.plan, rowKey: (row) => `${row.kind}-${row.file}`, cell: reviewCell, emptyText: "nothing to change" })), state.step === "apply" && (_jsxs(Box, { flexDirection: "column", children: [state.results.map((result, index) => (_jsx(Text, { children: applyResultLine(result) }, `${result.action.kind}-${index}`))), state.results.length < state.plan.length && _jsx(Text, { dimColor: true, children: "applying\u2026" })] })), state.step === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: `setup complete: ${state.results.length} change(s) applied` }), _jsx(Text, { dimColor: true, children: "enter open dashboard" })] }))] })) }));
186
+ const { stdout } = useStdout();
187
+ const width = columns ?? stdout?.columns ?? 80;
188
+ const height = rows ?? stdout?.rows ?? DEFAULT_ROWS;
189
+ const mainWidth = width;
190
+ const mainHeight = Math.max(height - HEADER_ROWS - FOOTER_ROWS, 0);
191
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(HeaderBar, { left: `>_ kankaku setup${version ? ` ${version}` : ""}`, width: width }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: _jsxs(Panel, { title: panelTitle(state), width: mainWidth, height: mainHeight, active: true, children: [state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected, facts.homeDir), cursor: agentCursor, onToggle: (id) => setState((s) => toggleAgent(s, id)), onMove: (delta) => setAgentCursor((c) => Math.min(Math.max(c + delta, 0), state.agents.length - 1)), focused: true })), state.step === "claude" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "kankaku-claude checkout path:" }), _jsx(TextInput, { value: state.claudeCheckout, onChange: (value) => setState((s) => setClaudeCheckout(s, value)), focused: true }), state.errors.claudeCheckout && _jsx(Text, { color: theme.error, children: state.errors.claudeCheckout })] })), state.step === "hub" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Radio, { options: HUB_MODE_OPTIONS, value: state.hub.mode, onChange: (mode) => setState((s) => setHubMode(s, mode)), focused: hubFocus === 0 }), state.hub.mode === "existing" && (_jsxs(Box, { flexDirection: "column", children: [["url", "email", "password"].map((field, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: `${field}: ` }), _jsx(TextInput, { value: state.hub[field], onChange: (value) => setState((s) => setHubField(s, field, value)), masked: field === "password", focused: hubFocus === index + 1 })] }, field))), state.errors.url && _jsx(Text, { color: theme.error, children: state.errors.url }), state.errors.email && _jsx(Text, { color: theme.error, children: state.errors.email }), state.errors.password && _jsx(Text, { color: theme.error, children: state.errors.password }), _jsx(Text, { children: `[ check ] c ${healthLine(state.hub.healthOk, healthChecking)}` })] })), state.hub.mode === "local" && hasCheckoutField && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "local checkout path:" }), _jsx(TextInput, { value: checkoutValue, onChange: (value) => setState((s) => setLocalCheckout(s, value)), focused: hubFocus === 1 })] })), state.hub.mode === "local" && !hasCheckoutField && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no kankaku-hub checkout found; run these commands, then check again:" }), actions.manualCommands(state.hub.localCheckout || undefined).map((line) => (_jsx(Text, { children: ` ${line}` }, line))), _jsx(Text, { children: `[ check again ] c ${healthLine(state.hub.healthOk, healthChecking)}` }), _jsx(Text, { children: `[ m ] acknowledge manual install${state.hub.localManual ? " (acknowledged)" : ""}` }), state.errors.localCheckout && _jsx(Text, { color: theme.error, children: state.errors.localCheckout })] }))] })), state.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
192
+ setRootsText(value);
193
+ setState((s) => setRoots(s, value));
194
+ }, focused: true }), state.errors.roots && _jsx(Text, { color: theme.error, children: state.errors.roots })] })), state.step === "review" && (_jsx(Table, { columns: reviewColumns(mainWidth), rows: state.plan, rowKey: (row) => `${row.kind}-${row.file}`, cell: reviewCell, emptyText: "nothing to change" })), state.step === "apply" && (_jsxs(Box, { flexDirection: "column", children: [state.results.map((result, index) => (_jsx(Text, { children: applyResultLine(result) }, `${result.action.kind}-${index}`))), state.results.length < state.plan.length && _jsx(Text, { dimColor: true, children: "applying\u2026" })] })), state.step === "done" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: `setup complete: ${state.results.length} change(s) applied` }), _jsx(Text, { dimColor: true, children: "enter open dashboard" })] }))] }) }), _jsx(KeyHints, { hints: hints })] }));
198
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-tui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Terminal app that shows today's kankaku work across every project on disk",
5
5
  "license": "MIT",
6
6
  "type": "module",