kankaku 1.1.0 → 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.
- package/README.md +68 -5
- package/dist/adapters/hub-reassign.js +115 -0
- package/dist/cli.js +17 -1
- package/dist/domain/nav-model.js +8 -0
- package/dist/domain/reassign-model.js +101 -0
- package/dist/domain/reassign-picker.js +258 -0
- package/dist/ports/reassign-actions.js +1 -0
- package/dist/ui/app.js +9 -4
- package/dist/ui/components/table.js +2 -2
- package/dist/ui/layout.js +3 -2
- package/dist/ui/reassign-panel.js +36 -0
- package/dist/ui/tasks-screen.js +167 -10
- package/package.json +3 -3
- package/vendor/kankaku-pi/package.json +1 -1
package/README.md
CHANGED
|
@@ -445,7 +445,8 @@ the hub has no credentials — in which case `c`/`s`/`S` do nothing.
|
|
|
445
445
|
- **Tasks** — a table (time, project, work, cost, prompt) with a
|
|
446
446
|
highlighted row on the left, and a `[ Task ]` detail panel on the right
|
|
447
447
|
showing the selected row's full prompt, client, project, hub task,
|
|
448
|
-
wall/work/wait time, cost, cache hit and subagent count.
|
|
448
|
+
wall/work/wait time, cost, cache hit and subagent count. `m` and `M`
|
|
449
|
+
move (reassign) hub rows (see "Reassigning from the Tasks screen" below).
|
|
449
450
|
- **Catalog** — `[ Clients ]` on the left; the selected client's
|
|
450
451
|
`[ Projects ]`, with open/doing hub task counts, on the right. The
|
|
451
452
|
Clients panel header shows the cache's age and a `(stale)` flag.
|
|
@@ -453,6 +454,64 @@ the hub has no credentials — in which case `c`/`s`/`S` do nothing.
|
|
|
453
454
|
highlighted, and each action's result line shows inside its card while
|
|
454
455
|
it runs and once it settles.
|
|
455
456
|
|
|
457
|
+
### Reassigning from the Tasks screen
|
|
458
|
+
|
|
459
|
+
Sync never changes the assignment of a row that is already on the hub
|
|
460
|
+
(it stays create-only, so a reassignment made anywhere is never undone by
|
|
461
|
+
a later sync). The Tasks screen is the explicit way to change it from
|
|
462
|
+
kankaku: with the content zone focused, `m` moves (reassigns) the selected task
|
|
463
|
+
and `M` reassigns every task of the current view (the project filter and
|
|
464
|
+
the today/all scope) whose hub row is on the unassigned client.
|
|
465
|
+
|
|
466
|
+
Before anything opens, kankaku asks the hub for the rows involved (the
|
|
467
|
+
footer shows `asking the hub…`) and refreshes the catalog. If the hub is
|
|
468
|
+
not configured, rejects the credentials or cannot be reached, the footer
|
|
469
|
+
says so and nothing opens; so does `m` on a task that is not on the hub
|
|
470
|
+
yet (`not on the hub yet — sync first`) and `M` when no task of the view
|
|
471
|
+
is unassigned there.
|
|
472
|
+
|
|
473
|
+
The picker is a panel over the content zone with its own footer hints:
|
|
474
|
+
|
|
475
|
+
1. **Client** — the active clients, with the unassigned client last, as
|
|
476
|
+
`unassigned`.
|
|
477
|
+
2. **Project** — the client's active projects, plus `no project`.
|
|
478
|
+
3. **Task** — the project's tasks that are not done, plus `no task`.
|
|
479
|
+
Skipped when `no project` was chosen.
|
|
480
|
+
4. **Review** — one line per row, `current → new` by names, the count of
|
|
481
|
+
rows that will change and what will not be touched. `enter` applies.
|
|
482
|
+
5. **Result** — one line per row (`reassigned`, `unchanged`, or
|
|
483
|
+
`failed: <reason>`); a failure never stops the remaining rows. `enter`
|
|
484
|
+
or `esc` returns to the list, which then shows the hub's assignment
|
|
485
|
+
(`hub …`) in the detail panel next to the local one, for the rows asked
|
|
486
|
+
about in this session.
|
|
487
|
+
|
|
488
|
+
`↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move inside a step, `enter`
|
|
489
|
+
advances and `esc` (or `←`) goes back one step, closing the picker at the
|
|
490
|
+
first. While the picker is open, the screen's other keys and the app's
|
|
491
|
+
own `esc`/`←`/`Tab`/`1`-`4` are inert; `q` still quits.
|
|
492
|
+
|
|
493
|
+
What it does and does not do:
|
|
494
|
+
|
|
495
|
+
- Each changed row gets one `PATCH` of `{ client, project, task }` (an
|
|
496
|
+
empty relation is sent as `""`). Nothing else is written: not
|
|
497
|
+
`legacy_client_label`, not any measurement field.
|
|
498
|
+
- The selection is checked against the catalog before any request: the
|
|
499
|
+
project must belong to the client, the task to the project, all
|
|
500
|
+
active, the task not done. The picker cannot build an inconsistent
|
|
501
|
+
choice, and the planner rejects one anyway (the hub itself only checks
|
|
502
|
+
that each id exists).
|
|
503
|
+
- `M` only ever touches rows that are on the unassigned client on the
|
|
504
|
+
hub; a row that already has a real assignment is never changed by it.
|
|
505
|
+
Use `m` on that task to move it deliberately.
|
|
506
|
+
- The local worklog is never rewritten; the Tasks screen keeps showing
|
|
507
|
+
the local assignment. After a reassignment the hub's assignment is shown
|
|
508
|
+
next to it for the rows asked about in this session.
|
|
509
|
+
- Tasks that are not on the hub yet must be synced first (Sync screen or
|
|
510
|
+
`kankaku sync`).
|
|
511
|
+
- The hub keeps no record of who reassigned a row or when. The
|
|
512
|
+
service account must be allowed to update `task_entries` (the hub's
|
|
513
|
+
`owner` and `service` roles are).
|
|
514
|
+
|
|
456
515
|
## Keys (TUI)
|
|
457
516
|
|
|
458
517
|
The app has two focus zones — the sidebar and the active screen's own main
|
|
@@ -488,9 +547,11 @@ jump to the first/last row.
|
|
|
488
547
|
(filtered to it), `r` refresh; the Quick actions panel additionally
|
|
489
548
|
takes `c` (refresh catalog), `s` (sync all projects) and `S` (full sync
|
|
490
549
|
all) — one at a time, ignored while another is running.
|
|
491
|
-
- **Tasks** — `a` toggle today/all,
|
|
492
|
-
move the selection, `r` refresh,
|
|
493
|
-
|
|
550
|
+
- **Tasks** — `a` toggle today/all,
|
|
551
|
+
`↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the selection, `r` refresh,
|
|
552
|
+
`m` move (reassign) the selected task on the hub, `M` move every task of
|
|
553
|
+
the view that is unassigned on the hub (both open the picker, see above),
|
|
554
|
+
`esc` clears a project filter set from Dashboard.
|
|
494
555
|
- **Catalog** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the client
|
|
495
556
|
selection, `r` refresh from the hub.
|
|
496
557
|
- **Sync** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the selection,
|
|
@@ -502,4 +563,6 @@ The TUI never writes to disk on its own — Dashboard, Tasks and read-only
|
|
|
502
563
|
Catalog views write nothing at all; Catalog's `refresh`, Sync's
|
|
503
564
|
`s`/`f`/`S` and Dashboard's Quick actions `c`/`s`/`S` write only through
|
|
504
565
|
kankaku's own adapters (`CachedCatalog`, `SyncStateStore`, the hub
|
|
505
|
-
itself), exactly as kankaku's own sync paths do.
|
|
566
|
+
itself), exactly as kankaku's own sync paths do. Tasks' `m`/`M` write
|
|
567
|
+
nothing to disk either (apart from the catalog cache refresh): they change
|
|
568
|
+
the hub's rows only, after the picker's Review step.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub side of the Tasks screen's reassignment: read the `task_entries`
|
|
3
|
+
* rows of some kankaku tasks (by `task_id`, in chunks) and PATCH their
|
|
4
|
+
* `client`/`project`/`task` relations, both through the published
|
|
5
|
+
* `kankaku-pi/hub` client, so authentication, the 401 re-auth and the
|
|
6
|
+
* per-request time bound are the client's own. Sync stays create-only:
|
|
7
|
+
* this is the only path that changes an existing row's assignment, and it
|
|
8
|
+
* sends nothing but those three relations (never `legacy_client_label`,
|
|
9
|
+
* never a measurement field).
|
|
10
|
+
*/
|
|
11
|
+
import { PocketBaseClient, PocketBaseError, escapeFilterValue } from "kankaku-pi/hub";
|
|
12
|
+
import { createCatalog } from "./hub.js";
|
|
13
|
+
/** Task ids per lookup request, per the hub contract's guidance (and the sync sink's own chunking). */
|
|
14
|
+
const LOOKUP_CHUNK_SIZE = 30;
|
|
15
|
+
/** Overall bound for `prepare` (row lookups plus the catalog refresh), on top of each request's own time bound. */
|
|
16
|
+
const PREPARE_TIMEOUT_MS = 20_000;
|
|
17
|
+
function relation(value) {
|
|
18
|
+
return typeof value === "string" ? value : "";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The hub rows of `taskIds`, keyed by kankaku task id. A task with no row
|
|
22
|
+
* is simply absent from the map. An empty relation reads `""`. A hub
|
|
23
|
+
* failure propagates as the client's `PocketBaseError`.
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchHubRows(client, taskIds, options = {}) {
|
|
26
|
+
const chunkSize = options.chunkSize ?? LOOKUP_CHUNK_SIZE;
|
|
27
|
+
const rows = new Map();
|
|
28
|
+
for (let start = 0; start < taskIds.length; start += chunkSize) {
|
|
29
|
+
const chunk = taskIds.slice(start, start + chunkSize);
|
|
30
|
+
const filter = chunk.map((taskId) => `task_id="${escapeFilterValue(taskId)}"`).join("||");
|
|
31
|
+
const items = await client.list("task_entries", { filter, perPage: chunkSize }, options.signal);
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const taskId = item["task_id"];
|
|
34
|
+
if (typeof taskId !== "string")
|
|
35
|
+
continue;
|
|
36
|
+
rows.set(taskId, {
|
|
37
|
+
rowId: item.id,
|
|
38
|
+
taskId,
|
|
39
|
+
clientId: relation(item["client"]),
|
|
40
|
+
projectId: relation(item["project"]),
|
|
41
|
+
hubTaskId: relation(item["task"]),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return rows;
|
|
46
|
+
}
|
|
47
|
+
/** A short, plain reason for a failed hub call: never a URL, a row id or a stack. */
|
|
48
|
+
export function describeHubError(error) {
|
|
49
|
+
if (!(error instanceof PocketBaseError))
|
|
50
|
+
return error instanceof Error ? error.message : String(error);
|
|
51
|
+
switch (error.kind) {
|
|
52
|
+
case "auth":
|
|
53
|
+
return "the hub rejected the credentials";
|
|
54
|
+
case "timeout":
|
|
55
|
+
return "the request timed out";
|
|
56
|
+
case "network":
|
|
57
|
+
return "the hub is unreachable";
|
|
58
|
+
case "http":
|
|
59
|
+
if (error.status === 400)
|
|
60
|
+
return "the hub rejected the change (400)";
|
|
61
|
+
if (error.status === 403)
|
|
62
|
+
return "not allowed to change this row (403)";
|
|
63
|
+
if (error.status === 404)
|
|
64
|
+
return "the row no longer exists on the hub (404)";
|
|
65
|
+
return `the hub answered ${error.status ?? "an error"}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* One `PATCH /api/collections/task_entries/records/<rowId>` per `reassign`
|
|
70
|
+
* line, body `{ client, project, task }` (`""` for an empty relation).
|
|
71
|
+
* Rows are sent one after the other; a failure is recorded for its row and
|
|
72
|
+
* the rest still run. Returns one outcome per `reassign` line, in order.
|
|
73
|
+
*/
|
|
74
|
+
export async function applyReassignment(client, plan) {
|
|
75
|
+
const outcomes = [];
|
|
76
|
+
for (const line of plan.lines) {
|
|
77
|
+
if (line.kind !== "reassign")
|
|
78
|
+
continue;
|
|
79
|
+
const body = { client: line.payload.client, project: line.payload.project, task: line.payload.task };
|
|
80
|
+
try {
|
|
81
|
+
await client.request("PATCH", `/api/collections/task_entries/records/${encodeURIComponent(line.rowId)}`, body);
|
|
82
|
+
outcomes.push({ taskId: line.taskId, status: "reassigned" });
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
outcomes.push({ taskId: line.taskId, status: "failed", reason: describeHubError(error) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return outcomes;
|
|
89
|
+
}
|
|
90
|
+
/** Build the {@link ReassignActions} for `credentials`: one client for the row lookups and the PATCHes, the disk-backed catalog for the refresh. */
|
|
91
|
+
export function createReassignActions(credentials, deps) {
|
|
92
|
+
const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
|
|
93
|
+
const catalog = createCatalog(credentials, deps);
|
|
94
|
+
return {
|
|
95
|
+
async prepare(taskIds) {
|
|
96
|
+
const signal = AbortSignal.timeout(PREPARE_TIMEOUT_MS);
|
|
97
|
+
try {
|
|
98
|
+
const rows = await fetchHubRows(client, taskIds, { signal });
|
|
99
|
+
const snapshot = await catalog.refresh(signal);
|
|
100
|
+
if (snapshot === undefined)
|
|
101
|
+
return { ok: false, message: "could not refresh the catalog from the hub" };
|
|
102
|
+
return { ok: true, rows, catalog: { clients: snapshot.clients, projects: snapshot.projects, tasks: snapshot.tasks ?? [] } };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return { ok: false, message: describeHubError(error) };
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
async apply(plan) {
|
|
109
|
+
const outcomes = await applyReassignment(client, plan);
|
|
110
|
+
// Task statuses may have moved (opening a task marks it as doing); a failed refresh only leaves the cache as it was.
|
|
111
|
+
await catalog.refresh().catch(() => undefined);
|
|
112
|
+
return outcomes;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { buildSyncStatusLines, formatCatalogRefreshLines, formatSyncSummaryLines
|
|
|
11
11
|
import { readTuiConfig } from "./adapters/tui-config.js";
|
|
12
12
|
import { discoverProjects } from "./adapters/project-discovery.js";
|
|
13
13
|
import { readProjectRecords } from "./adapters/worklog-reader.js";
|
|
14
|
+
import { createReassignActions } from "./adapters/hub-reassign.js";
|
|
14
15
|
import { computeProjectSyncStatus, createCatalog, refreshCatalog as refreshCatalogAdapter, resolveHub, syncProject } from "./adapters/hub.js";
|
|
15
16
|
import { readOwnVersion } from "./adapters/app-info.js";
|
|
16
17
|
import { readCarriedVersions } from "./adapters/package-versions.js";
|
|
@@ -823,6 +824,21 @@ function catalogScreenDeps(deps) {
|
|
|
823
824
|
refresh: async () => buildCatalogModel(await refreshCatalogAdapter(catalog), now()),
|
|
824
825
|
};
|
|
825
826
|
}
|
|
827
|
+
/**
|
|
828
|
+
* Build the Tasks screen's reassignment deps for the interactive app; used
|
|
829
|
+
* only by `renderApp`. Without hub credentials, `prepare` reports why (the
|
|
830
|
+
* screen shows it in the footer and opens nothing) and `apply` is never
|
|
831
|
+
* reached.
|
|
832
|
+
*/
|
|
833
|
+
export function reassignScreenDeps(deps) {
|
|
834
|
+
const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
835
|
+
if (!hub.ok) {
|
|
836
|
+
const reason = hub.reason;
|
|
837
|
+
return { prepare: async () => ({ ok: false, message: reason }), apply: async () => [] };
|
|
838
|
+
}
|
|
839
|
+
const { now, fetch: fetchOverride } = envDeps(deps);
|
|
840
|
+
return createReassignActions(hub.credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
|
|
841
|
+
}
|
|
826
842
|
/** Build the Sync screen's `load`/`syncOne`/`syncAll` deps for the interactive app; used only by `renderApp`. */
|
|
827
843
|
function syncScreenDeps(deps, roots) {
|
|
828
844
|
const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
|
|
@@ -956,7 +972,7 @@ if (isMain) {
|
|
|
956
972
|
isTTY: () => process.stdout.isTTY === true,
|
|
957
973
|
prompter: createReadlinePrompter(process.stdin, process.stdout),
|
|
958
974
|
renderApp: (roots, theme, options) => {
|
|
959
|
-
render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), dashboardActions: dashboardActionsDeps(realDeps, roots), wizard: {
|
|
975
|
+
render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), reassign: reassignScreenDeps(realDeps), dashboardActions: dashboardActionsDeps(realDeps, roots), wizard: {
|
|
960
976
|
facts: gatherWizardFacts(realDeps, options?.claudePluginDir ?? process.env["KANKAKU_CLAUDE_PLUGIN_DIR"]),
|
|
961
977
|
actions: buildWizardActions(realDeps, options?.claudePluginDir ?? process.env["KANKAKU_CLAUDE_PLUGIN_DIR"]),
|
|
962
978
|
}, startInWizard: options?.startInWizard }) }), { alternateScreen: true, exitOnCtrlC: true });
|
package/dist/domain/nav-model.js
CHANGED
|
@@ -27,6 +27,14 @@ export function clearProjectFilter(state) {
|
|
|
27
27
|
void projectFilter;
|
|
28
28
|
return rest;
|
|
29
29
|
}
|
|
30
|
+
/** Mark or clear the active screen's modal flow; clearing removes the key, so a state without a modal equals the plain one. */
|
|
31
|
+
export function setModal(state, open) {
|
|
32
|
+
if (open)
|
|
33
|
+
return { ...state, modal: true };
|
|
34
|
+
const { modal, ...rest } = state;
|
|
35
|
+
void modal;
|
|
36
|
+
return rest;
|
|
37
|
+
}
|
|
30
38
|
/** Focus the main content zone (e.g. `enter`/`→`/`Tab` from the sidebar). */
|
|
31
39
|
export function focusMain(state) {
|
|
32
40
|
return { ...state, focus: "main" };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Why a task is left out of a reassignment plan. User-facing text, shown verbatim. */
|
|
2
|
+
export const NOT_ON_HUB = "not on the hub yet — sync first";
|
|
3
|
+
export const ALREADY_ASSIGNED = "already assigned";
|
|
4
|
+
/** How the catalog's unassigned client is named in every user-facing string. */
|
|
5
|
+
export const UNASSIGNED_LABEL = "unassigned";
|
|
6
|
+
function unassignedClient(clients) {
|
|
7
|
+
return clients.find((client) => client.unassigned === true);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `Client`, `Client · Project` or `Client · Project › Task`, by names; the
|
|
11
|
+
* unassigned client reads `unassigned`, a relation the catalog no longer
|
|
12
|
+
* knows reads `unknown …`, never an id.
|
|
13
|
+
*/
|
|
14
|
+
export function describeAssignment(assignment, catalog) {
|
|
15
|
+
const client = catalog.clients.find((candidate) => candidate.id === assignment.clientId);
|
|
16
|
+
const clientText = assignment.clientId === "" ? "no client" : client === undefined ? "unknown client" : client.unassigned === true ? UNASSIGNED_LABEL : client.name;
|
|
17
|
+
if (assignment.projectId === "")
|
|
18
|
+
return clientText;
|
|
19
|
+
const project = catalog.projects.find((candidate) => candidate.id === assignment.projectId);
|
|
20
|
+
const projectText = project?.name ?? "unknown project";
|
|
21
|
+
if (assignment.hubTaskId === "")
|
|
22
|
+
return `${clientText} · ${projectText}`;
|
|
23
|
+
const task = catalog.tasks.find((candidate) => candidate.id === assignment.hubTaskId);
|
|
24
|
+
return `${clientText} · ${projectText} › ${task?.title ?? "unknown task"}`;
|
|
25
|
+
}
|
|
26
|
+
/** The targets whose hub row exists and sits on the catalog's unassigned client: what `M` may touch. */
|
|
27
|
+
export function eligibleForBulk(targets, rows, catalog) {
|
|
28
|
+
const unassigned = unassignedClient(catalog.clients);
|
|
29
|
+
if (unassigned === undefined)
|
|
30
|
+
return [];
|
|
31
|
+
return targets.filter((target) => rows.get(target.taskId)?.clientId === unassigned.id);
|
|
32
|
+
}
|
|
33
|
+
/** Validate `selection` against the catalog; returns the error message, or `undefined` when it is consistent. */
|
|
34
|
+
function validateSelection(selection, catalog) {
|
|
35
|
+
const client = catalog.clients.find((candidate) => candidate.id === selection.clientId);
|
|
36
|
+
if (client === undefined)
|
|
37
|
+
return "unknown client";
|
|
38
|
+
if (!client.active)
|
|
39
|
+
return `client "${client.name}" is not active`;
|
|
40
|
+
const projectId = selection.projectId ?? "";
|
|
41
|
+
const hubTaskId = selection.hubTaskId ?? "";
|
|
42
|
+
if (projectId === "") {
|
|
43
|
+
return hubTaskId === "" ? undefined : "a task needs a project";
|
|
44
|
+
}
|
|
45
|
+
const project = catalog.projects.find((candidate) => candidate.id === projectId);
|
|
46
|
+
if (project === undefined)
|
|
47
|
+
return "unknown project";
|
|
48
|
+
if (!project.active)
|
|
49
|
+
return `project "${project.name}" is not active`;
|
|
50
|
+
if (project.clientId !== client.id)
|
|
51
|
+
return `project "${project.name}" does not belong to client "${client.name}"`;
|
|
52
|
+
if (hubTaskId === "")
|
|
53
|
+
return undefined;
|
|
54
|
+
const task = catalog.tasks.find((candidate) => candidate.id === hubTaskId);
|
|
55
|
+
if (task === undefined)
|
|
56
|
+
return "unknown task";
|
|
57
|
+
if (task.projectId !== project.id)
|
|
58
|
+
return `task "${task.title}" does not belong to project "${project.name}"`;
|
|
59
|
+
if (task.status === "done")
|
|
60
|
+
return `task "${task.title}" is done`;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Turn a destination into one plan line per asked task. The selection is
|
|
65
|
+
* validated first and rejects the whole plan, so an inconsistent choice
|
|
66
|
+
* never yields a payload. A task with no hub row is skipped (sync first);
|
|
67
|
+
* in `bulk` mode a row that is not on the unassigned client is skipped
|
|
68
|
+
* (`already assigned`); a row that already has exactly the destination is
|
|
69
|
+
* `unchanged`; the rest are `reassign` lines carrying the relation payload.
|
|
70
|
+
* Pure: no request is made here.
|
|
71
|
+
*/
|
|
72
|
+
export function planReassignment(input) {
|
|
73
|
+
const { rows, tasks, catalog, selection, mode } = input;
|
|
74
|
+
const error = validateSelection(selection, catalog);
|
|
75
|
+
if (error !== undefined)
|
|
76
|
+
return { ok: false, error };
|
|
77
|
+
const payload = { client: selection.clientId, project: selection.projectId ?? "", task: selection.hubTaskId ?? "" };
|
|
78
|
+
const destination = describeAssignment({ clientId: payload.client, projectId: payload.project, hubTaskId: payload.task }, catalog);
|
|
79
|
+
const unassigned = unassignedClient(catalog.clients);
|
|
80
|
+
const lines = tasks.map((target) => {
|
|
81
|
+
const row = rows.get(target.taskId);
|
|
82
|
+
if (row === undefined)
|
|
83
|
+
return { kind: "skipped", taskId: target.taskId, label: target.label, reason: NOT_ON_HUB };
|
|
84
|
+
if (mode === "bulk" && (unassigned === undefined || row.clientId !== unassigned.id)) {
|
|
85
|
+
return { kind: "skipped", taskId: target.taskId, label: target.label, reason: ALREADY_ASSIGNED };
|
|
86
|
+
}
|
|
87
|
+
if (row.clientId === payload.client && row.projectId === payload.project && row.hubTaskId === payload.task) {
|
|
88
|
+
return { kind: "unchanged", taskId: target.taskId, label: target.label, at: destination };
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
kind: "reassign",
|
|
92
|
+
taskId: target.taskId,
|
|
93
|
+
label: target.label,
|
|
94
|
+
rowId: row.rowId,
|
|
95
|
+
from: describeAssignment(row, catalog),
|
|
96
|
+
to: destination,
|
|
97
|
+
payload: { ...payload },
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
return { ok: true, plan: { mode, destination, lines } };
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/ui/tasks-screen.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { formatMinutes } from "../domain/today-model.js";
|
|
5
5
|
import { SCREENS, hintsFor } from "../domain/nav-model.js";
|
|
6
6
|
import { wrapText } from "../domain/text-wrap.js";
|
|
7
|
+
import { NOT_ON_HUB, describeAssignment, eligibleForBulk } from "../domain/reassign-model.js";
|
|
8
|
+
import { advance, back, finishApplying, jumpSelection, moveSelection, openPicker, pickerHints, pickerRowCount } from "../domain/reassign-picker.js";
|
|
9
|
+
import { ReassignPanel, pickerMaxRows } from "./reassign-panel.js";
|
|
7
10
|
import { Layout } from "./layout.js";
|
|
8
11
|
import { Panel } from "./components/panel.js";
|
|
9
12
|
import { Table } from "./components/table.js";
|
|
@@ -63,7 +66,7 @@ const DETAIL_CHROME_ROWS = 2;
|
|
|
63
66
|
* `muted` `… N more lines` line instead of letting Ink's own overflow
|
|
64
67
|
* clipping silently swallow the rest with no visible cue.
|
|
65
68
|
*/
|
|
66
|
-
function DetailPanel({ row, width, height }) {
|
|
69
|
+
function DetailPanel({ row, hub, width, height }) {
|
|
67
70
|
const theme = useTheme();
|
|
68
71
|
if (row === undefined) {
|
|
69
72
|
return (_jsx(Panel, { title: "Task", width: width, height: height, children: _jsx(Text, { dimColor: true, children: "no task selected" }) }));
|
|
@@ -77,6 +80,8 @@ function DetailPanel({ row, width, height }) {
|
|
|
77
80
|
metaLines.push({ text: `project ${row.projectName}`, dim: true });
|
|
78
81
|
if (row.hubTaskTitle !== undefined)
|
|
79
82
|
metaLines.push({ text: `task ${row.hubTaskTitle}`, dim: true });
|
|
83
|
+
if (hub !== undefined)
|
|
84
|
+
metaLines.push({ text: `hub ${hub}`, dim: true });
|
|
80
85
|
metaLines.push({ text: `wall ${formatMinutes(row.wallMs)} work ${formatMinutes(row.workMs)} wait ${formatMinutes(row.waitingMs)}`, dim: false });
|
|
81
86
|
metaLines.push({ text: `cost ${formatCost(row.cost)}${row.cacheHit !== undefined ? ` cache hit ${Math.round(row.cacheHit * 100)}%` : ""}`, dim: false });
|
|
82
87
|
metaLines.push({ text: `subagents ${row.subagentCount}`, dim: false });
|
|
@@ -86,38 +91,186 @@ function DetailPanel({ row, width, height }) {
|
|
|
86
91
|
const visiblePromptLines = truncated ? wrappedPrompt.slice(0, Math.max(promptBudget - 1, 0)) : wrappedPrompt.slice(0, promptBudget);
|
|
87
92
|
const hiddenCount = wrappedPrompt.length - visiblePromptLines.length;
|
|
88
93
|
const showIndicator = truncated && promptBudget > 0;
|
|
89
|
-
return (_jsx(Panel, { title: "Task", width: width, height: height, children: _jsxs(Box, { flexDirection: "column", children: [visiblePromptLines.map((line, index) => (_jsx(Text, { children: line }, `prompt-${index}`))), showIndicator && _jsx(Text, { color: theme.muted, children: `… ${hiddenCount} more lines` }), metaLines.map((line, index) => (_jsx(Text, { dimColor: line.dim, children: line.text }, `meta-${index}`)))] }) }));
|
|
94
|
+
return (_jsx(Panel, { title: "Task", width: width, height: height, children: _jsxs(Box, { flexDirection: "column", children: [visiblePromptLines.map((line, index) => (_jsx(Text, { children: line }, `prompt-${index}`))), showIndicator && _jsx(Text, { color: theme.muted, children: `… ${hiddenCount} more lines` }), metaLines.map((line, index) => (_jsx(Text, { dimColor: line.dim, wrap: "truncate-end", children: line.text }, `meta-${index}`)))] }) }));
|
|
90
95
|
}
|
|
91
96
|
const KEY_HINTS = [
|
|
92
97
|
{ key: "↑↓", label: "select" },
|
|
93
98
|
{ key: "a", label: "today/all" },
|
|
99
|
+
{ key: "m", label: "move" },
|
|
100
|
+
{ key: "M", label: "move all" },
|
|
94
101
|
{ key: "r", label: "refresh" },
|
|
95
|
-
{ key: "esc", label: "clear filter" },
|
|
96
102
|
{ key: "1-4", label: "screens" },
|
|
97
103
|
{ key: "q", label: "quit" },
|
|
98
104
|
];
|
|
105
|
+
/** With a project filter set, `esc` clears it; listed only then, so the footer stays on one line in narrower terminals. */
|
|
106
|
+
const FILTERED_KEY_HINTS = [...KEY_HINTS.slice(0, 5), { key: "esc", label: "clear filter" }, ...KEY_HINTS.slice(5)];
|
|
99
107
|
/** Below this main-content width, the detail panel stacks under the table instead of beside it. */
|
|
100
108
|
const DETAIL_BREAKPOINT = 70;
|
|
101
109
|
/** Rows the `Panel` chrome (top border line + bottom border line) and the `Table`'s own header line take, outside its data rows. */
|
|
102
110
|
const TABLE_CHROME_ROWS = 3;
|
|
111
|
+
/** Footer messages of the reassignment flow. */
|
|
112
|
+
const ASKING_HUB = "asking the hub…";
|
|
113
|
+
const REASSIGN_UNAVAILABLE = "reassignment is not available: no hub connection in this session";
|
|
114
|
+
const NOTHING_UNASSIGNED = "no unassigned tasks on the hub in this view";
|
|
115
|
+
function toTarget(row) {
|
|
116
|
+
return { taskId: row.id, label: row.prompt };
|
|
117
|
+
}
|
|
103
118
|
/**
|
|
104
119
|
* The Tasks screen: a table of tasks (via `domain/tasks-model.ts`, never
|
|
105
120
|
* reimplemented here) with a `[ Task ]` detail panel for the selected row.
|
|
106
121
|
* `a` toggles today/all, `r` reloads, `↑↓` move the selection. When
|
|
107
122
|
* `projectFilter` is set (via Today's `enter`), the table is restricted to
|
|
108
|
-
* that project and `esc` clears it through `onClearFilter`.
|
|
109
|
-
*
|
|
123
|
+
* that project and `esc` clears it through `onClearFilter`.
|
|
124
|
+
*
|
|
125
|
+
* `m` moves (reassigns) the selected task on the hub and `M` every task of the
|
|
126
|
+
* current view that is unassigned there: the screen asks the hub for the
|
|
127
|
+
* rows involved (`reassign.prepare`, a progress message in the footer
|
|
128
|
+
* meanwhile), then opens the picker (`domain/reassign-picker.ts`) as a
|
|
129
|
+
* panel over the content zone. The picker is a modal flow: while it is
|
|
130
|
+
* open (or the hub is being asked) every other key of the screen is inert.
|
|
131
|
+
* Nothing is written to disk; the hub is written only by `reassign.apply`,
|
|
132
|
+
* after the review step.
|
|
110
133
|
*/
|
|
111
|
-
export function TasksScreen({ load, roots, version, columns, rows, focused = true, projectFilter, onClearFilter }) {
|
|
134
|
+
export function TasksScreen({ load, roots, version, columns, rows, focused = true, projectFilter, onClearFilter, reassign, onModalChange }) {
|
|
112
135
|
const allRef = useRef(false);
|
|
113
136
|
const [all, setAll] = useState(false);
|
|
114
137
|
const [model, setModel] = useState(() => load({ all: false }));
|
|
115
138
|
const [selected, setSelected] = useState(0);
|
|
139
|
+
const [picker, setPickerState] = useState(undefined);
|
|
140
|
+
const pickerRef = useRef(undefined);
|
|
141
|
+
const [asking, setAsking] = useState(false);
|
|
142
|
+
const askingRef = useRef(false);
|
|
143
|
+
const [note, setNote] = useState(undefined);
|
|
144
|
+
/** The hub's assignment (by names) for the tasks asked about in this session, keyed by kankaku task id. */
|
|
145
|
+
const [hubAssignments, setHubAssignments] = useState({});
|
|
116
146
|
const visibleRows = useMemo(() => (projectFilter !== undefined ? model.rows.filter((row) => row.project === projectFilter) : model.rows), [model.rows, projectFilter]);
|
|
117
147
|
const maxRowsRef = useRef(0);
|
|
148
|
+
const pickerRowsRef = useRef(1);
|
|
149
|
+
const setPicker = (next) => {
|
|
150
|
+
pickerRef.current = next;
|
|
151
|
+
setPickerState(next);
|
|
152
|
+
};
|
|
153
|
+
const modalOpen = asking || picker !== undefined;
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
onModalChange?.(modalOpen);
|
|
156
|
+
}, [modalOpen]);
|
|
157
|
+
const rememberHubRows = (found, catalog) => {
|
|
158
|
+
setHubAssignments((current) => {
|
|
159
|
+
const next = { ...current };
|
|
160
|
+
for (const [taskId, hubRow] of found)
|
|
161
|
+
next[taskId] = describeAssignment(hubRow, catalog);
|
|
162
|
+
return next;
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
const startReassign = (mode) => {
|
|
166
|
+
if (reassign === undefined) {
|
|
167
|
+
setNote(REASSIGN_UNAVAILABLE);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const chosen = mode === "single" ? [visibleRows[Math.min(selected, Math.max(visibleRows.length - 1, 0))]] : visibleRows;
|
|
171
|
+
const targets = chosen.filter((row) => row !== undefined).map(toTarget);
|
|
172
|
+
if (targets.length === 0) {
|
|
173
|
+
setNote("no tasks to reassign");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
askingRef.current = true;
|
|
177
|
+
setAsking(true);
|
|
178
|
+
setNote(ASKING_HUB);
|
|
179
|
+
void reassign
|
|
180
|
+
.prepare(targets.map((target) => target.taskId))
|
|
181
|
+
.then((prepared) => {
|
|
182
|
+
if (!prepared.ok) {
|
|
183
|
+
setNote(prepared.message);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
rememberHubRows(prepared.rows, prepared.catalog);
|
|
187
|
+
if (mode === "single" && !prepared.rows.has(targets[0].taskId)) {
|
|
188
|
+
setNote(NOT_ON_HUB);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (mode === "bulk" && eligibleForBulk(targets, prepared.rows, prepared.catalog).length === 0) {
|
|
192
|
+
setNote(NOTHING_UNASSIGNED);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
setNote(undefined);
|
|
196
|
+
setPicker(openPicker({ mode, catalog: prepared.catalog, targets, rows: prepared.rows }));
|
|
197
|
+
})
|
|
198
|
+
.catch((error) => setNote(error instanceof Error ? error.message : String(error)))
|
|
199
|
+
.finally(() => {
|
|
200
|
+
askingRef.current = false;
|
|
201
|
+
setAsking(false);
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
const startApply = (state) => {
|
|
205
|
+
const plan = state.plan;
|
|
206
|
+
if (reassign === undefined || plan === undefined || !plan.ok)
|
|
207
|
+
return;
|
|
208
|
+
const settle = (outcomes) => {
|
|
209
|
+
const current = pickerRef.current ?? state;
|
|
210
|
+
setPicker(finishApplying(current, outcomes));
|
|
211
|
+
setHubAssignments((assignments) => {
|
|
212
|
+
const next = { ...assignments };
|
|
213
|
+
for (const line of plan.plan.lines) {
|
|
214
|
+
if (line.kind === "reassign" && outcomes.some((outcome) => outcome.taskId === line.taskId && outcome.status === "reassigned"))
|
|
215
|
+
next[line.taskId] = line.to;
|
|
216
|
+
}
|
|
217
|
+
return next;
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
void reassign.apply(plan.plan).then(settle, (error) => {
|
|
221
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
222
|
+
settle(plan.plan.lines.filter((line) => line.kind === "reassign").map((line) => ({ taskId: line.taskId, status: "failed", reason })));
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
const handlePickerKey = (current, key) => {
|
|
226
|
+
if (current.step === "applying")
|
|
227
|
+
return;
|
|
228
|
+
const page = Math.max(pickerRowsRef.current, 1);
|
|
229
|
+
if (key.escape || key.leftArrow) {
|
|
230
|
+
setPicker(back(current));
|
|
231
|
+
}
|
|
232
|
+
else if (key.return) {
|
|
233
|
+
const next = advance(current);
|
|
234
|
+
setPicker(next);
|
|
235
|
+
if (next !== undefined && next.step === "applying" && current.step === "review")
|
|
236
|
+
startApply(next);
|
|
237
|
+
}
|
|
238
|
+
else if (key.downArrow) {
|
|
239
|
+
setPicker(moveSelection(current, 1));
|
|
240
|
+
}
|
|
241
|
+
else if (key.upArrow) {
|
|
242
|
+
setPicker(moveSelection(current, -1));
|
|
243
|
+
}
|
|
244
|
+
else if (key.pageDown) {
|
|
245
|
+
setPicker(moveSelection(current, page));
|
|
246
|
+
}
|
|
247
|
+
else if (key.pageUp) {
|
|
248
|
+
setPicker(moveSelection(current, -page));
|
|
249
|
+
}
|
|
250
|
+
else if (key.home) {
|
|
251
|
+
setPicker(jumpSelection(current, "first"));
|
|
252
|
+
}
|
|
253
|
+
else if (key.end) {
|
|
254
|
+
setPicker(jumpSelection(current, "last"));
|
|
255
|
+
}
|
|
256
|
+
};
|
|
118
257
|
useInput((input, key) => {
|
|
258
|
+
if (askingRef.current)
|
|
259
|
+
return;
|
|
260
|
+
setNote(undefined);
|
|
261
|
+
const openPickerState = pickerRef.current;
|
|
262
|
+
if (openPickerState !== undefined) {
|
|
263
|
+
handlePickerKey(openPickerState, key);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
119
266
|
const lastIndex = Math.max(visibleRows.length - 1, 0);
|
|
120
|
-
if (input === "
|
|
267
|
+
if (input === "m") {
|
|
268
|
+
startReassign("single");
|
|
269
|
+
}
|
|
270
|
+
else if (input === "M") {
|
|
271
|
+
startReassign("bulk");
|
|
272
|
+
}
|
|
273
|
+
else if (input === "a") {
|
|
121
274
|
const nextAll = !allRef.current;
|
|
122
275
|
allRef.current = nextAll;
|
|
123
276
|
setAll(nextAll);
|
|
@@ -149,7 +302,11 @@ export function TasksScreen({ load, roots, version, columns, rows, focused = tru
|
|
|
149
302
|
onClearFilter?.();
|
|
150
303
|
}
|
|
151
304
|
}, { isActive: focused });
|
|
152
|
-
return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, headerRight: projectFilter !== undefined ? `filtered: ${projectFilter}` : undefined, sidebarItems: SCREENS, activeId: "tasks", sidebarStats: [`tasks ${visibleRows.length}`, all ? "scope all" : "scope today"], keyHints: hintsFor("tasks", focused ? "main" : "sidebar", KEY_HINTS), focus: focused ? "main" : "sidebar", children: ({ mainWidth, mainHeight }) => {
|
|
305
|
+
return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, headerRight: projectFilter !== undefined ? `filtered: ${projectFilter}` : undefined, sidebarItems: SCREENS, activeId: "tasks", sidebarStats: [`tasks ${visibleRows.length}`, all ? "scope all" : "scope today"], keyHints: picker !== undefined ? pickerHints(picker) : hintsFor("tasks", focused ? "main" : "sidebar", projectFilter !== undefined ? FILTERED_KEY_HINTS : KEY_HINTS), ...(note !== undefined ? { footerNote: note } : {}), focus: focused ? "main" : "sidebar", children: ({ mainWidth, mainHeight }) => {
|
|
306
|
+
if (picker !== undefined) {
|
|
307
|
+
pickerRowsRef.current = pickerMaxRows(mainHeight);
|
|
308
|
+
return _jsx(ReassignPanel, { state: picker, width: mainWidth, height: mainHeight });
|
|
309
|
+
}
|
|
153
310
|
const wide = mainWidth >= DETAIL_BREAKPOINT;
|
|
154
311
|
const tableWidth = wide ? Math.floor(mainWidth * 0.62) : mainWidth;
|
|
155
312
|
const detailWidth = wide ? Math.max(mainWidth - tableWidth - 1, 1) : mainWidth;
|
|
@@ -160,7 +317,7 @@ export function TasksScreen({ load, roots, version, columns, rows, focused = tru
|
|
|
160
317
|
const maxRows = Math.max(tableHeight - TABLE_CHROME_ROWS, 1);
|
|
161
318
|
maxRowsRef.current = maxRows;
|
|
162
319
|
const table = (_jsx(Panel, { title: "Tasks", width: tableWidth, height: wide ? mainHeight : tableHeight, active: focused, children: _jsx(Table, { columns: taskColumns(tableWidth), rows: visibleRows, rowKey: (row) => row.id, cell: taskCell, selectedIndex: selected, emptyText: "no tasks", maxRows: maxRows }) }));
|
|
163
|
-
const detail = _jsx(DetailPanel, { row: selectedRow, width: detailWidth, height: detailHeight });
|
|
320
|
+
const detail = _jsx(DetailPanel, { row: selectedRow, hub: selectedRow !== undefined ? hubAssignments[selectedRow.id] : undefined, width: detailWidth, height: detailHeight });
|
|
164
321
|
return wide ? (_jsxs(Box, { flexDirection: "row", children: [table, _jsx(Box, { width: 1 }), detail] })) : (_jsxs(Box, { flexDirection: "column", children: [table, detail] }));
|
|
165
322
|
} }));
|
|
166
323
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kankaku",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Terminal app that shows today's kankaku work across every project on disk",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"ink": "^7.1.1",
|
|
41
|
-
"kankaku-pi": "^1.
|
|
42
|
-
"kankaku-claude": "^1.
|
|
41
|
+
"kankaku-pi": "^1.2.0",
|
|
42
|
+
"kankaku-claude": "^1.2.0",
|
|
43
43
|
"kankaku-hub": "^0.2.0",
|
|
44
44
|
"react": "^19.3.0"
|
|
45
45
|
},
|