kankaku 1.0.2 → 1.2.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.
@@ -0,0 +1,258 @@
1
+ import { ALREADY_ASSIGNED, NOT_ON_HUB, UNASSIGNED_LABEL, eligibleForBulk, planReassignment } from "./reassign-model.js";
2
+ /** The id of the "no project" / "no task" option: an empty relation. */
3
+ export const NO_CHOICE = "";
4
+ export function openPicker(input) {
5
+ return { step: "client", index: 0, ...input };
6
+ }
7
+ function clientLabel(name, unassigned) {
8
+ return unassigned === true ? UNASSIGNED_LABEL : name;
9
+ }
10
+ /** The selectable options of a choosing step (`client`, `project`, `task`); empty on every other step. */
11
+ export function pickerOptions(state) {
12
+ const { catalog } = state;
13
+ if (state.step === "client") {
14
+ const active = catalog.clients.filter((client) => client.active);
15
+ const ordered = [...active.filter((client) => client.unassigned !== true), ...active.filter((client) => client.unassigned === true)];
16
+ return ordered.map((client) => ({ id: client.id, label: clientLabel(client.name, client.unassigned) }));
17
+ }
18
+ if (state.step === "project") {
19
+ const projects = catalog.projects.filter((project) => project.active && project.clientId === state.clientId);
20
+ return [...projects.map((project) => ({ id: project.id, label: project.name })), { id: NO_CHOICE, label: "no project" }];
21
+ }
22
+ if (state.step === "task") {
23
+ const tasks = catalog.tasks.filter((task) => task.projectId === state.projectId && task.status !== "done");
24
+ return [...tasks.map((task) => ({ id: task.id, label: task.title })), { id: NO_CHOICE, label: "no task" }];
25
+ }
26
+ return [];
27
+ }
28
+ function plural(count, noun) {
29
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
30
+ }
31
+ function lines(state) {
32
+ return state.plan?.ok ? state.plan.plan.lines : [];
33
+ }
34
+ function outcomeFor(state, taskId) {
35
+ return state.outcomes?.find((outcome) => outcome.taskId === taskId);
36
+ }
37
+ function skippedGroups(planLines) {
38
+ const groups = [];
39
+ for (const reason of [ALREADY_ASSIGNED, NOT_ON_HUB]) {
40
+ const count = planLines.filter((line) => line.kind === "skipped" && line.reason === reason).length;
41
+ if (count > 0)
42
+ groups.push({ text: `${count} not touched — ${reason}`, tone: "muted" });
43
+ }
44
+ return groups;
45
+ }
46
+ /** The scrolling list of the current step, as display rows. */
47
+ export function pickerRows(state) {
48
+ if (state.step === "review") {
49
+ if (state.plan === undefined || !state.plan.ok)
50
+ return [{ text: `cannot reassign: ${state.plan?.ok === false ? state.plan.error : "no plan"}`, tone: "error" }];
51
+ const planLines = lines(state);
52
+ const rows = [];
53
+ for (const line of planLines) {
54
+ if (line.kind === "reassign")
55
+ rows.push({ text: `${line.from} → ${line.to} · ${line.label}` });
56
+ else if (line.kind === "unchanged")
57
+ rows.push({ text: `already ${line.at} (unchanged) · ${line.label}`, tone: "muted" });
58
+ }
59
+ return [...rows, ...skippedGroups(planLines)];
60
+ }
61
+ if (state.step === "result" || state.step === "applying") {
62
+ const planLines = lines(state);
63
+ const rows = [];
64
+ for (const line of planLines) {
65
+ if (line.kind === "unchanged")
66
+ rows.push({ text: `unchanged · ${line.label}`, tone: "muted" });
67
+ if (line.kind !== "reassign")
68
+ continue;
69
+ if (state.step === "applying") {
70
+ rows.push({ text: `sending · ${line.label}` });
71
+ continue;
72
+ }
73
+ const outcome = outcomeFor(state, line.taskId);
74
+ if (outcome === undefined)
75
+ rows.push({ text: `failed: no answer from the hub · ${line.label}`, tone: "error" });
76
+ else if (outcome.status === "reassigned")
77
+ rows.push({ text: `reassigned · ${line.label}` });
78
+ else
79
+ rows.push({ text: `failed: ${outcome.reason} · ${line.label}`, tone: "error" });
80
+ }
81
+ return [...rows, ...skippedGroups(planLines)];
82
+ }
83
+ return pickerOptions(state).map((option) => ({ text: option.label }));
84
+ }
85
+ /** How many lines the current step's list has (what `moveSelection` clamps to). */
86
+ export function pickerRowCount(state) {
87
+ return pickerRows(state).length;
88
+ }
89
+ function clamp(value, count) {
90
+ return Math.min(Math.max(value, 0), Math.max(count - 1, 0));
91
+ }
92
+ /** Move the selection by `delta` lines, clamped to the list (never wraps). */
93
+ export function moveSelection(state, delta) {
94
+ return { ...state, index: clamp(state.index + delta, pickerRowCount(state)) };
95
+ }
96
+ export function jumpSelection(state, where) {
97
+ return { ...state, index: where === "first" ? 0 : clamp(Number.MAX_SAFE_INTEGER, pickerRowCount(state)) };
98
+ }
99
+ function withPlan(state) {
100
+ const selection = {
101
+ clientId: state.clientId ?? "",
102
+ ...(state.projectId !== undefined ? { projectId: state.projectId } : {}),
103
+ ...(state.hubTaskId !== undefined ? { hubTaskId: state.hubTaskId } : {}),
104
+ };
105
+ const plan = planReassignment({ rows: state.rows, tasks: state.targets, catalog: state.catalog, selection, mode: state.mode });
106
+ return { ...state, step: "review", index: 0, plan };
107
+ }
108
+ /**
109
+ * `enter`: take the selected option and go to the next step, or apply from
110
+ * review. Returns `undefined` when the picker closes (from `result`); an
111
+ * empty option list, a rejected plan and the `applying` step leave the
112
+ * state as it is.
113
+ */
114
+ export function advance(state) {
115
+ if (state.step === "result")
116
+ return undefined;
117
+ if (state.step === "applying")
118
+ return state;
119
+ if (state.step === "review") {
120
+ if (state.plan === undefined || !state.plan.ok)
121
+ return state;
122
+ const hasChanges = state.plan.plan.lines.some((line) => line.kind === "reassign");
123
+ return hasChanges ? { ...state, step: "applying", index: 0 } : { ...state, step: "result", index: 0, outcomes: [] };
124
+ }
125
+ const option = pickerOptions(state)[state.index];
126
+ if (option === undefined)
127
+ return state;
128
+ if (state.step === "client")
129
+ return { ...state, step: "project", index: 0, clientId: option.id };
130
+ if (state.step === "project") {
131
+ if (option.id === NO_CHOICE)
132
+ return withPlan({ ...state, projectId: undefined });
133
+ return { ...state, step: "task", index: 0, projectId: option.id };
134
+ }
135
+ return withPlan({ ...state, hubTaskId: option.id === NO_CHOICE ? undefined : option.id });
136
+ }
137
+ function without(state, ...keys) {
138
+ const copy = { ...state };
139
+ for (const key of keys)
140
+ delete copy[key];
141
+ return copy;
142
+ }
143
+ /** Select the option `id` in the list of `state` (used to land on the previous choice when going back). */
144
+ function selecting(state, id) {
145
+ const index = pickerOptions(state).findIndex((option) => option.id === (id ?? NO_CHOICE));
146
+ return { ...state, index: Math.max(index, 0) };
147
+ }
148
+ /** `esc`/`←`: go back one step, restoring the previous choice; returns `undefined` when the picker closes (from `client` or `result`). */
149
+ export function back(state) {
150
+ switch (state.step) {
151
+ case "client":
152
+ case "result":
153
+ return undefined;
154
+ case "applying":
155
+ return state;
156
+ case "project":
157
+ return selecting(without({ ...state, step: "client" }, "clientId", "projectId", "hubTaskId", "plan"), state.clientId);
158
+ case "task":
159
+ return selecting(without({ ...state, step: "project" }, "projectId", "hubTaskId", "plan"), state.projectId);
160
+ case "review":
161
+ return state.projectId !== undefined
162
+ ? selecting(without({ ...state, step: "task" }, "plan"), state.hubTaskId)
163
+ : selecting(without({ ...state, step: "project" }, "projectId", "hubTaskId", "plan"), undefined);
164
+ }
165
+ }
166
+ /** The hub answered every PATCH: show the result. */
167
+ export function finishApplying(state, outcomes) {
168
+ return { ...state, step: "result", index: 0, outcomes };
169
+ }
170
+ const STEP_TITLES = {
171
+ client: "Client",
172
+ project: "Project",
173
+ task: "Task",
174
+ review: "Review",
175
+ applying: "Applying",
176
+ result: "Result",
177
+ };
178
+ export function pickerTitle(state) {
179
+ return `Reassign · ${STEP_TITLES[state.step]}`;
180
+ }
181
+ function subject(state) {
182
+ if (state.mode === "single")
183
+ return `"${state.targets[0]?.label ?? ""}"`;
184
+ return plural(eligibleForBulk(state.targets, state.rows, state.catalog).length, "unassigned task");
185
+ }
186
+ function chosenClientName(state) {
187
+ const client = state.catalog.clients.find((candidate) => candidate.id === state.clientId);
188
+ return client === undefined ? "unknown client" : clientLabel(client.name, client.unassigned);
189
+ }
190
+ /** The two fixed lines above the list: what is being reassigned and what to do or what happened. Always two entries so the layout never shifts. */
191
+ export function pickerHeading(state) {
192
+ switch (state.step) {
193
+ case "client":
194
+ return [`Reassign ${subject(state)}`, "Choose a client"];
195
+ case "project":
196
+ return [`Reassign ${subject(state)}`, `${chosenClientName(state)} — choose a project`];
197
+ case "task": {
198
+ const project = state.catalog.projects.find((candidate) => candidate.id === state.projectId);
199
+ return [`Reassign ${subject(state)}`, `${chosenClientName(state)} · ${project?.name ?? "unknown project"} — choose a task`];
200
+ }
201
+ case "review": {
202
+ if (state.plan === undefined || !state.plan.ok)
203
+ return ["Cannot reassign", ""];
204
+ const planLines = state.plan.plan.lines;
205
+ const changes = planLines.filter((line) => line.kind === "reassign").length;
206
+ const unchanged = planLines.filter((line) => line.kind === "unchanged").length;
207
+ const skipped = planLines.filter((line) => line.kind === "skipped").length;
208
+ const detail = [unchanged > 0 ? `${unchanged} unchanged` : "", skipped > 0 ? `${skipped} not touched` : ""].filter((part) => part !== "").join(" · ");
209
+ return [changes > 0 ? `Move ${plural(changes, "task")} to ${state.plan.plan.destination}` : "Nothing to change", detail];
210
+ }
211
+ case "applying":
212
+ return ["Applying…", "sending the changes to the hub"];
213
+ case "result": {
214
+ const planLines = lines(state);
215
+ const outcomes = state.outcomes ?? [];
216
+ const done = outcomes.filter((outcome) => outcome.status === "reassigned").length;
217
+ const failed = planLines.filter((line) => line.kind === "reassign").length - done;
218
+ const unchanged = planLines.filter((line) => line.kind === "unchanged").length;
219
+ const skipped = planLines.filter((line) => line.kind === "skipped").length;
220
+ const headline = [done > 0 ? `${done} reassigned` : "", unchanged > 0 ? `${unchanged} unchanged` : "", failed > 0 ? `${failed} failed` : ""].filter((part) => part !== "").join(" · ");
221
+ return [headline === "" ? "Nothing changed" : headline, skipped > 0 ? `${skipped} not touched` : ""];
222
+ }
223
+ }
224
+ }
225
+ /** The footer key hints for the current step. */
226
+ export function pickerHints(state) {
227
+ switch (state.step) {
228
+ case "client":
229
+ return [
230
+ { key: "↑↓", label: "move" },
231
+ { key: "enter", label: "next" },
232
+ { key: "esc", label: "close" },
233
+ ];
234
+ case "project":
235
+ case "task":
236
+ return [
237
+ { key: "↑↓", label: "move" },
238
+ { key: "enter", label: "next" },
239
+ { key: "esc", label: "back" },
240
+ ];
241
+ case "review":
242
+ if (state.plan === undefined || !state.plan.ok)
243
+ return [{ key: "esc", label: "back" }];
244
+ return [
245
+ { key: "↑↓", label: "scroll" },
246
+ { key: "enter", label: "apply" },
247
+ { key: "esc", label: "back" },
248
+ ];
249
+ case "applying":
250
+ return [{ key: "…", label: "applying, please wait" }];
251
+ case "result":
252
+ return [
253
+ { key: "↑↓", label: "scroll" },
254
+ { key: "enter", label: "done" },
255
+ { key: "esc", label: "done" },
256
+ ];
257
+ }
258
+ }
@@ -5,8 +5,9 @@
5
5
  * callers (`src/ui/setup/*`, `src/cli.tsx`) read the real files and pass
6
6
  * plain facts in via `WizardFacts`, exactly like `domain/setup-plan.ts`.
7
7
  */
8
+ import { shouldWriteCredentials } from "./local-hub-model.js";
8
9
  import { detectAgents } from "./setup-plan.js";
9
- /** The local hub's fixed port for the wizard's `install locally` review line and `install-local-hub` action — mirrors `hub-manager/install.ts#DEFAULT_HUB_PORT`. */
10
+ /** The local hub's default port, where the wizard's port field starts looking for a free one - mirrors `hub-manager/install.ts#DEFAULT_HUB_PORT`. */
10
11
  export const DEFAULT_HUB_PORT = 8090;
11
12
  /**
12
13
  * Shorten `path` for display by replacing a leading `homeDir` with `~`.
@@ -31,7 +32,7 @@ function clearError(errors, key) {
31
32
  delete rest[key];
32
33
  return rest;
33
34
  }
34
- const HUB_ERROR_KEYS = ["url", "email", "password", "ownerEmail", "ownerPassword"];
35
+ const HUB_ERROR_KEYS = ["url", "email", "password", "ownerEmail", "ownerPassword", "port"];
35
36
  function clearHubErrors(errors) {
36
37
  return HUB_ERROR_KEYS.reduce((acc, key) => clearError(acc, key), errors);
37
38
  }
@@ -50,6 +51,7 @@ export function createWizardState(facts) {
50
51
  password: facts.hub.password ?? "",
51
52
  ownerEmail: "",
52
53
  ownerPassword: "",
54
+ port: String(facts.hub.localPort ?? DEFAULT_HUB_PORT),
53
55
  },
54
56
  roots: facts.roots.current ?? facts.roots.defaultRoots,
55
57
  errors: {},
@@ -68,6 +70,17 @@ export function setHubMode(state, mode) {
68
70
  export function setHubField(state, field, value) {
69
71
  return { ...state, hub: { ...state.hub, [field]: value }, errors: clearError(state.errors, field) };
70
72
  }
73
+ /** The local hub port as a number when `text` is digits only within 1024-65535, otherwise `undefined`. */
74
+ export function parseHubPort(text) {
75
+ if (!/^\d+$/.test(text))
76
+ return undefined;
77
+ const port = Number(text);
78
+ return port >= 1024 && port <= 65535 ? port : undefined;
79
+ }
80
+ /** Flags `port` as occupied (`port N is in use`), found by the probe the Hub step runs before advancing; editing the port field clears it. */
81
+ export function setHubPortInUse(state, port) {
82
+ return { ...state, errors: { ...state.errors, port: `port ${port} is in use` } };
83
+ }
71
84
  export function setHubHealth(state, ok) {
72
85
  return { ...state, hub: { ...state.hub, healthOk: ok } };
73
86
  }
@@ -96,6 +109,8 @@ function validateHub(hub) {
96
109
  errors.ownerEmail = "enter a valid email";
97
110
  if (hub.ownerPassword.trim() === "")
98
111
  errors.ownerPassword = "enter a password";
112
+ if (parseHubPort(hub.port) === undefined)
113
+ errors.port = "enter a port between 1024 and 65535";
99
114
  return errors;
100
115
  }
101
116
  return {};
@@ -140,8 +155,12 @@ export function planFromWizard(state, facts) {
140
155
  }
141
156
  }
142
157
  else if (state.hub.mode === "local") {
143
- const url = `http://127.0.0.1:${DEFAULT_HUB_PORT}`;
144
- actions.push({ kind: "install-local-hub", file: url, label: `install a local hub at ${url}` });
158
+ const url = `http://127.0.0.1:${parseHubPort(state.hub.port) ?? DEFAULT_HUB_PORT}`;
159
+ const existingUrl = facts.hub.credentialsPresent ? facts.hub.url : undefined;
160
+ const note = shouldWriteCredentials(existingUrl, url)
161
+ ? "sync credentials → the local hub"
162
+ : `sync credentials stay on ${existingUrl} (switch later with kankaku hub use)`;
163
+ actions.push({ kind: "install-local-hub", file: url, label: `install a local hub at ${url}`, note });
145
164
  }
146
165
  const sameRoots = facts.roots.current !== undefined && arraysEqual(facts.roots.current, state.roots);
147
166
  if (!sameRoots) {
@@ -0,0 +1,5 @@
1
+ /** Pure text for `kankaku --version`. No I/O: `adapters/package-versions.ts` reads the versions. */
2
+ /** `kankaku <version>`, then one indented line per carried package: its version, or `not found`. */
3
+ export function formatVersionLines(ownVersion, carried) {
4
+ return [`kankaku ${ownVersion}`, ...carried.map(({ name, version }) => ` ${name} ${version ?? "not found"}`)];
5
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/ui/app.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
3
  import { useApp, useInput } from "ink";
4
- import { INITIAL_NAV_STATE, clearProjectFilter, focusMain, focusSidebar, moveSidebar, openProjectInTasks, screenForKey, switchScreen, } from "../domain/nav-model.js";
4
+ import { INITIAL_NAV_STATE, clearProjectFilter, focusMain, focusSidebar, moveSidebar, openProjectInTasks, screenForKey, setModal, switchScreen, } from "../domain/nav-model.js";
5
5
  import { DashboardScreen } from "./dashboard-screen.js";
6
6
  import { TasksScreen } from "./tasks-screen.js";
7
7
  import { CatalogScreen } from "./catalog-screen.js";
@@ -23,9 +23,12 @@ import { SetupWizard } from "./setup/wizard-screen.js";
23
23
  * to it, keeping focus on main; Tasks' own `esc` handling (gated the same
24
24
  * way as every other screen key, by its `focused` prop) clears that
25
25
  * filter — this hook only predicts whether that will happen, to decide
26
- * whether it should also move focus.
26
+ * whether it should also move focus. While the Tasks screen has its
27
+ * reassignment picker open (`NavState.modal`, reported through its
28
+ * `onModalChange`), only `q` is handled here: `esc`, `←`, `Tab` and `1`-`4`
29
+ * belong to the picker, so it can never be left half-way by accident.
27
30
  */
28
- export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashboardActions, wizard, startInWizard }) {
31
+ export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashboardActions, wizard, startInWizard, reassign }) {
29
32
  const { exit } = useApp();
30
33
  const [nav, setNav] = useState(INITIAL_NAV_STATE);
31
34
  const [showWizard, setShowWizard] = useState(startInWizard === true && wizard !== undefined);
@@ -34,6 +37,8 @@ export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashb
34
37
  exit();
35
38
  return;
36
39
  }
40
+ if (nav.modal === true)
41
+ return;
37
42
  const nextScreen = screenForKey(input);
38
43
  if (nextScreen !== undefined) {
39
44
  setNav((state) => switchScreen(state, nextScreen));
@@ -70,5 +75,5 @@ export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashb
70
75
  if (showWizard && wizard) {
71
76
  return _jsx(SetupWizard, { facts: wizard.facts, actions: wizard.actions, onDone: () => setShowWizard(false), onQuit: () => exit(), version: version });
72
77
  }
73
- return (_jsxs(_Fragment, { children: [nav.screen === "dashboard" && (_jsx(DashboardScreen, { load: loadToday, actions: dashboardActions, roots: roots, version: version, focused: focused, onOpenProject: (project) => setNav((state) => openProjectInTasks(state, project)) })), nav.screen === "tasks" && (_jsx(TasksScreen, { load: loadTasks, roots: roots, version: version, focused: focused, ...(nav.projectFilter !== undefined ? { projectFilter: nav.projectFilter } : {}), onClearFilter: () => setNav((state) => clearProjectFilter(state)) })), nav.screen === "catalog" && _jsx(CatalogScreen, { ...catalog, roots: roots, version: version, focused: focused }), nav.screen === "sync" && _jsx(SyncScreen, { ...sync, roots: roots, version: version, focused: focused })] }));
78
+ return (_jsxs(_Fragment, { children: [nav.screen === "dashboard" && (_jsx(DashboardScreen, { load: loadToday, actions: dashboardActions, roots: roots, version: version, focused: focused, onOpenProject: (project) => setNav((state) => openProjectInTasks(state, project)) })), nav.screen === "tasks" && (_jsx(TasksScreen, { load: loadTasks, roots: roots, version: version, focused: focused, ...(nav.projectFilter !== undefined ? { projectFilter: nav.projectFilter } : {}), onClearFilter: () => setNav((state) => clearProjectFilter(state)), onModalChange: (open) => setNav((state) => (state.modal === true) === open ? state : setModal(state, open)), ...(reassign !== undefined ? { reassign } : {}) })), nav.screen === "catalog" && _jsx(CatalogScreen, { ...catalog, roots: roots, version: version, focused: focused }), nav.screen === "sync" && _jsx(SyncScreen, { ...sync, roots: roots, version: version, focused: focused })] }));
74
79
  }
@@ -39,7 +39,7 @@ function rowText(columns, values) {
39
39
  * than its column truncates with `…`; `emptyText` renders instead of the
40
40
  * body when `rows` is empty.
41
41
  */
42
- export function Table({ columns, rows, rowKey, cell, selectedIndex, emptyText = "no rows", maxRows }) {
42
+ export function Table({ columns, rows, rowKey, cell, selectedIndex, rowColor, emptyText = "no rows", maxRows }) {
43
43
  const theme = useTheme();
44
44
  const startRef = useRef(0);
45
45
  const windowed = maxRows !== undefined && rows.length > maxRows;
@@ -50,6 +50,6 @@ export function Table({ columns, rows, rowKey, cell, selectedIndex, emptyText =
50
50
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.muted, children: ` ${rowText(columns, (column) => column.header)}` }), rows.length === 0 ? (_jsx(Text, { color: theme.muted, children: emptyText })) : (_jsxs(_Fragment, { children: [window !== undefined && window.hiddenAbove > 0 && (_jsx(Text, { color: theme.muted, children: ` ↑ ${window.hiddenAbove} more` }, "__indicator-above__")), visibleRows.map((row, index) => {
51
51
  const actualIndex = (window?.start ?? 0) + index;
52
52
  const selected = actualIndex === selectedIndex;
53
- return (_jsx(Text, { backgroundColor: selected ? theme.selectionBg : undefined, children: `${selected ? "› " : " "}${rowText(columns, (column) => cell(row, column.key))}` }, rowKey(row)));
53
+ return (_jsx(Text, { color: rowColor?.(row), backgroundColor: selected ? theme.selectionBg : undefined, children: `${selected ? "› " : " "}${rowText(columns, (column) => cell(row, column.key))}` }, rowKey(row)));
54
54
  }), window !== undefined && window.hiddenBelow > 0 && (_jsx(Text, { color: theme.muted, children: ` ↓ ${window.hiddenBelow} more` }, "__indicator-below__"))] }))] }));
55
55
  }
package/dist/ui/layout.js CHANGED
@@ -39,7 +39,8 @@ function TabStrip({ items, activeId }) {
39
39
  * own panels and scrolling lists to the actual `mainWidth`/`mainHeight`
40
40
  * in whichever mode is active.
41
41
  */
42
- export function Layout({ columns, rows, headerLeft, headerRight, sidebarItems, activeId, sidebarStats, keyHints, focus = "sidebar", children }) {
42
+ export function Layout({ columns, rows, headerLeft, headerRight, sidebarItems, activeId, sidebarStats, keyHints, footerNote, focus = "sidebar", children }) {
43
+ const theme = useTheme();
43
44
  const { stdout } = useStdout();
44
45
  const width = columns ?? stdout?.columns ?? 80;
45
46
  const height = rows ?? stdout?.rows ?? DEFAULT_ROWS;
@@ -50,5 +51,5 @@ export function Layout({ columns, rows, headerLeft, headerRight, sidebarItems, a
50
51
  const chromeRows = mode === "collapsed" ? TAB_STRIP_ROWS : mode === "stacked" ? sidebarBlockRows(sidebarItems.length, sidebarStats.length) : 0;
51
52
  const mainHeight = Math.max(middleRows - chromeRows, 0);
52
53
  const content = children({ mainWidth, mainHeight, mode });
53
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(HeaderBar, { left: headerLeft, right: headerRight, width: width }), mode === "collapsed" && (_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: [_jsx(TabStrip, { items: sidebarItems, activeId: activeId }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: content })] })), mode === "stacked" && (_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: [_jsx(Sidebar, { items: sidebarItems, activeId: activeId, stats: sidebarStats, width: width, focused: sidebarFocused }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: content })] })), mode === "wide" && (_jsxs(Box, { flexDirection: "row", flexGrow: 1, minHeight: 0, alignItems: "stretch", children: [_jsx(Sidebar, { items: sidebarItems, activeId: activeId, stats: sidebarStats, width: SIDEBAR_WIDTH, focused: sidebarFocused }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: content })] })), _jsx(KeyHints, { hints: keyHints })] }));
54
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(HeaderBar, { left: headerLeft, right: headerRight, width: width }), mode === "collapsed" && (_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: [_jsx(TabStrip, { items: sidebarItems, activeId: activeId }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: content })] })), mode === "stacked" && (_jsxs(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: [_jsx(Sidebar, { items: sidebarItems, activeId: activeId, stats: sidebarStats, width: width, focused: sidebarFocused }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: content })] })), mode === "wide" && (_jsxs(Box, { flexDirection: "row", flexGrow: 1, minHeight: 0, alignItems: "stretch", children: [_jsx(Sidebar, { items: sidebarItems, activeId: activeId, stats: sidebarStats, width: SIDEBAR_WIDTH, focused: sidebarFocused }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, overflow: "hidden", children: content })] })), footerNote !== undefined ? _jsx(Text, { color: theme.warn, children: footerNote }) : _jsx(KeyHints, { hints: keyHints })] }));
54
55
  }
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { pickerHeading, pickerRows, pickerTitle } from "../domain/reassign-picker.js";
4
+ import { Panel } from "./components/panel.js";
5
+ import { Table } from "./components/table.js";
6
+ import { useTheme } from "./theme.js";
7
+ /** Rows the panel's own chrome takes (top border line + bottom border) plus the two heading lines and the table's header line. */
8
+ const PICKER_FIXED_ROWS = 2 + 2 + 1;
9
+ /** Columns the panel border (2) and the table's `› ` marker (2) take from the panel width. */
10
+ const PICKER_FIXED_COLUMNS = 4;
11
+ const COLUMN_HEADERS = {
12
+ client: "client",
13
+ project: "project",
14
+ task: "task",
15
+ review: "change",
16
+ applying: "sending",
17
+ result: "result",
18
+ };
19
+ /** How many list lines fit a picker panel of `height` rows (at least one, so a tiny terminal still tracks the selection). */
20
+ export function pickerMaxRows(height) {
21
+ return Math.max(height - PICKER_FIXED_ROWS, 1);
22
+ }
23
+ /**
24
+ * The reassignment picker, drawn as one fixed-size `Panel` over the Tasks
25
+ * screen's content zone: two heading lines, then the current step's list
26
+ * in a windowed `Table` (the same scrolling and `↑ N more`/`↓ N more`
27
+ * indicators as every other list). The panel's `height` is fixed by the
28
+ * caller, so a long list scrolls inside it and never grows the frame.
29
+ */
30
+ export function ReassignPanel({ state, width, height }) {
31
+ const theme = useTheme();
32
+ const [headline, detail] = pickerHeading(state);
33
+ const rows = pickerRows(state).map((entry, index) => ({ ...entry, key: String(index) }));
34
+ const textWidth = Math.max(width - PICKER_FIXED_COLUMNS, 1);
35
+ return (_jsxs(Panel, { title: pickerTitle(state), width: width, height: height, active: true, children: [_jsx(Text, { wrap: "truncate-end", children: headline }), _jsx(Text, { color: theme.muted, wrap: "truncate-end", children: detail === "" ? " " : detail }), _jsx(Table, { columns: [{ key: "text", header: COLUMN_HEADERS[state.step], width: textWidth }], rows: rows, rowKey: (row) => row.key, cell: (row) => row.text, rowColor: (row) => (row.tone === "error" ? theme.error : row.tone === "muted" ? theme.muted : undefined), selectedIndex: state.index, emptyText: "nothing to choose from", maxRows: pickerMaxRows(height) }, state.step)] }));
36
+ }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { Box, Text, useInput, useStdout } from "ink";
4
- import { DEFAULT_HUB_PORT, applyResult, back, createWizardState, hintsForStep, next, planFromWizard, setHubField, setHubHealth, setHubMode, setRoots, shortenHome, toggleAgent, } from "../../domain/setup-wizard.js";
4
+ import { DEFAULT_HUB_PORT, applyResult, back, createWizardState, hintsForStep, next, parseHubPort, planFromWizard, setHubField, setHubHealth, setHubMode, setHubPortInUse, setRoots, shortenHome, toggleAgent, } from "../../domain/setup-wizard.js";
5
5
  import { HeaderBar } from "../components/header-bar.js";
6
6
  import { KeyHints } from "../components/key-hints.js";
7
7
  import { Panel } from "../components/panel.js";
@@ -111,11 +111,27 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
111
111
  const [healthChecking, setHealthChecking] = useState(false);
112
112
  const [rootsText, setRootsText] = useState(() => state.roots.join(", "));
113
113
  const applyStartedRef = useRef(false);
114
+ const portTouchedRef = useRef(false);
114
115
  useEffect(() => {
115
116
  if (state.step === "hub")
116
117
  setHubFocus(0);
117
118
  }, [state.step]);
118
- const hubFieldCount = state.hub.mode === "existing" ? 4 : state.hub.mode === "local" ? 3 : 1;
119
+ // Prefill the local hub's port with the first free one from the default upwards, unless an install already recorded its own or the user got there first.
120
+ useEffect(() => {
121
+ if (facts.hub.localPort !== undefined)
122
+ return;
123
+ let cancelled = false;
124
+ void actions.suggestPort(DEFAULT_HUB_PORT).then((port) => {
125
+ if (cancelled || port === undefined || portTouchedRef.current)
126
+ return;
127
+ setState((s) => setHubField(s, "port", String(port)));
128
+ });
129
+ return () => {
130
+ cancelled = true;
131
+ };
132
+ // eslint-disable-next-line react-hooks/exhaustive-deps
133
+ }, []);
134
+ const hubFieldCount = state.hub.mode === "existing" ? 4 : state.hub.mode === "local" ? 4 : 1;
119
135
  const textInputFocused = state.step === "roots" || (state.step === "hub" && hubFocus >= 1 && hubFocus < hubFieldCount);
120
136
  useEffect(() => {
121
137
  if (state.step !== "apply" || applyStartedRef.current)
@@ -140,6 +156,14 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
140
156
  onDone();
141
157
  return;
142
158
  }
159
+ if (state.step === "hub" && state.hub.mode === "local") {
160
+ // A valid port is probed before advancing; an existing install's own port is ours and is never probed.
161
+ const port = parseHubPort(state.hub.port);
162
+ if (port !== undefined && port !== facts.hub.localPort) {
163
+ void actions.isPortFree(port).then((free) => setState((s) => (free ? next(s, facts) : setHubPortInUse(s, port))));
164
+ return;
165
+ }
166
+ }
143
167
  setState((s) => next(s, facts));
144
168
  return;
145
169
  }
@@ -172,8 +196,11 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
172
196
  const height = rows ?? stdout?.rows ?? DEFAULT_ROWS;
173
197
  const mainWidth = width;
174
198
  const mainHeight = Math.max(height - HEADER_ROWS - FOOTER_ROWS, 0);
175
- 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 === "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" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: `will install and run at http://127.0.0.1:${DEFAULT_HUB_PORT}` }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "owner email: " }), _jsx(TextInput, { value: state.hub.ownerEmail, onChange: (value) => setState((s) => setHubField(s, "ownerEmail", value)), focused: hubFocus === 1 })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "owner password: " }), _jsx(TextInput, { value: state.hub.ownerPassword, onChange: (value) => setState((s) => setHubField(s, "ownerPassword", value)), masked: true, focused: hubFocus === 2 })] }), state.errors.ownerEmail && _jsx(Text, { color: theme.error, children: state.errors.ownerEmail }), state.errors.ownerPassword && _jsx(Text, { color: theme.error, children: state.errors.ownerPassword })] }))] })), state.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
199
+ 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 === "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" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: `will install and run at http://127.0.0.1:${parseHubPort(state.hub.port) ?? DEFAULT_HUB_PORT}` }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "owner email: " }), _jsx(TextInput, { value: state.hub.ownerEmail, onChange: (value) => setState((s) => setHubField(s, "ownerEmail", value)), focused: hubFocus === 1 })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "owner password: " }), _jsx(TextInput, { value: state.hub.ownerPassword, onChange: (value) => setState((s) => setHubField(s, "ownerPassword", value)), masked: true, focused: hubFocus === 2 })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "port: " }), _jsx(TextInput, { value: state.hub.port, onChange: (value) => {
200
+ portTouchedRef.current = true;
201
+ setState((s) => setHubField(s, "port", value.replace(/\D/g, "")));
202
+ }, focused: hubFocus === 3 })] }), state.errors.ownerEmail && _jsx(Text, { color: theme.error, children: state.errors.ownerEmail }), state.errors.ownerPassword && _jsx(Text, { color: theme.error, children: state.errors.ownerPassword }), state.errors.port && _jsx(Text, { color: theme.error, children: state.errors.port })] }))] })), state.step === "roots" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "project roots (comma-separated):" }), _jsx(TextInput, { value: rootsText, onChange: (value) => {
176
203
  setRootsText(value);
177
204
  setState((s) => setRoots(s, value));
178
- }, 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 })] }));
205
+ }, focused: true }), state.errors.roots && _jsx(Text, { color: theme.error, children: state.errors.roots })] })), state.step === "review" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Table, { columns: reviewColumns(mainWidth), rows: state.plan, rowKey: (row) => `${row.kind}-${row.file}`, cell: reviewCell, emptyText: "nothing to change" }), state.plan.map((action) => action.note !== undefined && _jsx(Text, { children: action.note }, `note-${action.kind}-${action.file}`))] })), 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 })] }));
179
206
  }