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.
@@ -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`. Never writes
109
- * anything to disk.
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 === "a") {
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.0.2",
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.0.2",
42
- "kankaku-claude": "^1.0.2",
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
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-pi",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -58,8 +58,8 @@
58
58
  "prepublishOnly": "npm run check"
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-coding-agent": "0.85.1",
62
- "@earendil-works/pi-tui": "^0.85.1",
61
+ "@earendil-works/pi-coding-agent": "0.87.1",
62
+ "@earendil-works/pi-tui": "^0.87.1",
63
63
  "@types/node": "^24.13.4",
64
64
  "typescript": "^5.7.0"
65
65
  }