kankaku-tui 0.4.1 → 0.9.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 CHANGED
@@ -8,6 +8,9 @@ in. Built with [Ink](https://github.com/vadimdemedes/ink) on Node 24. Four
8
8
  screens — Dashboard, Tasks, Catalog and Sync — share one tab bar, and each
9
9
  has a plain-text subcommand for scripts and cron.
10
10
 
11
+ This package lives in the [kankaku monorepo](https://github.com/soyunninja/kankaku),
12
+ under `packages/tui`.
13
+
11
14
  ## Install everything
12
15
 
13
16
  ```
@@ -33,9 +36,10 @@ The wizard's steps, `enter` to advance and `esc` to go back throughout
33
36
  agent schedules removing kankaku from it, not just skipping it. Codex
34
37
  and OpenCode are listed but disabled, showing `no adapter yet` (found,
35
38
  but kankaku can't write its config) or `not installed` (not found).
36
- 2. **Claude Code** — the `kankaku-claude` checkout path (only shown when
37
- Claude Code is checked and not already configured), guessed from any
38
- existing `statusLine`.
39
+ 2. **Claude Code** — the `kankaku-claude` checkout path (packages/claude
40
+ inside a kankaku monorepo checkout; only shown when Claude Code is
41
+ checked and not already configured), guessed from any existing
42
+ `statusLine`.
39
43
  3. **Hub** — `use an existing hub` (URL, email, masked password, a `c`
40
44
  inline health check, reusing the current credentials as the default),
41
45
  `install locally`, or `skip`. Installing locally looks for a
@@ -1,7 +1,6 @@
1
1
  /**
2
- * Locates the installed `kankaku-hub` npm package (a real dependency —
3
- * `file:../kankaku-hub-worktrees/npm-package` until the package is
4
- * published) and reads/validates its `hub-manifest.json`.
2
+ * Locates the installed `kankaku-hub` npm package (a real registry
3
+ * dependency) and reads/validates its `hub-manifest.json`.
5
4
  */
6
5
  import { createRequire } from "node:module";
7
6
  import { readFileSync } from "node:fs";
@@ -173,7 +173,7 @@ export function next(state, facts) {
173
173
  return { ...state, step: claudeStepNeeded(state) ? "claude" : "hub", errors: {} };
174
174
  case "claude": {
175
175
  if (state.claudeCheckout.trim() === "") {
176
- return { ...state, errors: { ...state.errors, claudeCheckout: "enter the kankaku-claude checkout path" } };
176
+ return { ...state, errors: { ...state.errors, claudeCheckout: "enter the kankaku-claude checkout path (packages/claude in a kankaku checkout)" } };
177
177
  }
178
178
  return { ...state, step: "hub", errors: clearError(state.errors, "claudeCheckout") };
179
179
  }
@@ -178,7 +178,7 @@ export function SetupWizard({ facts, actions, onDone, onQuit, version, columns,
178
178
  const height = rows ?? stdout?.rows ?? DEFAULT_ROWS;
179
179
  const mainWidth = width;
180
180
  const mainHeight = Math.max(height - HEADER_ROWS - FOOTER_ROWS, 0);
181
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(HeaderBar, { left: `>_ kankaku setup${version ? ` ${version}` : ""}`, width: width }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: _jsxs(Panel, { title: panelTitle(state), width: mainWidth, height: mainHeight, active: true, children: [state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected, facts.homeDir), cursor: agentCursor, onToggle: (id) => setState((s) => toggleAgent(s, id)), onMove: (delta) => setAgentCursor((c) => Math.min(Math.max(c + delta, 0), state.agents.length - 1)), focused: true })), state.step === "claude" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "kankaku-claude checkout path:" }), _jsx(TextInput, { value: state.claudeCheckout, onChange: (value) => setState((s) => setClaudeCheckout(s, value)), focused: true }), state.errors.claudeCheckout && _jsx(Text, { color: theme.error, children: state.errors.claudeCheckout })] })), state.step === "hub" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Radio, { options: HUB_MODE_OPTIONS, value: state.hub.mode, onChange: (mode) => setState((s) => setHubMode(s, mode)), focused: hubFocus === 0 }), state.hub.mode === "existing" && (_jsxs(Box, { flexDirection: "column", children: [["url", "email", "password"].map((field, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: `${field}: ` }), _jsx(TextInput, { value: state.hub[field], onChange: (value) => setState((s) => setHubField(s, field, value)), masked: field === "password", focused: hubFocus === index + 1 })] }, field))), state.errors.url && _jsx(Text, { color: theme.error, children: state.errors.url }), state.errors.email && _jsx(Text, { color: theme.error, children: state.errors.email }), state.errors.password && _jsx(Text, { color: theme.error, children: state.errors.password }), _jsx(Text, { children: `[ check ] c ${healthLine(state.hub.healthOk, healthChecking)}` })] })), state.hub.mode === "local" && (_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) => {
181
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(HeaderBar, { left: `>_ kankaku setup${version ? ` ${version}` : ""}`, width: width }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minHeight: 0, children: _jsxs(Panel, { title: panelTitle(state), width: mainWidth, height: mainHeight, active: true, children: [state.step === "agents" && (_jsx(Checklist, { items: agentChecklistItems(state.agents, state.selected, facts.homeDir), cursor: agentCursor, onToggle: (id) => setState((s) => toggleAgent(s, id)), onMove: (delta) => setAgentCursor((c) => Math.min(Math.max(c + delta, 0), state.agents.length - 1)), focused: true })), state.step === "claude" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "kankaku-claude checkout path (packages/claude in a kankaku checkout):" }), _jsx(TextInput, { value: state.claudeCheckout, onChange: (value) => setState((s) => setClaudeCheckout(s, value)), focused: true }), state.errors.claudeCheckout && _jsx(Text, { color: theme.error, children: state.errors.claudeCheckout })] })), state.step === "hub" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Radio, { options: HUB_MODE_OPTIONS, value: state.hub.mode, onChange: (mode) => setState((s) => setHubMode(s, mode)), focused: hubFocus === 0 }), state.hub.mode === "existing" && (_jsxs(Box, { flexDirection: "column", children: [["url", "email", "password"].map((field, index) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: `${field}: ` }), _jsx(TextInput, { value: state.hub[field], onChange: (value) => setState((s) => setHubField(s, field, value)), masked: field === "password", focused: hubFocus === index + 1 })] }, field))), state.errors.url && _jsx(Text, { color: theme.error, children: state.errors.url }), state.errors.email && _jsx(Text, { color: theme.error, children: state.errors.email }), state.errors.password && _jsx(Text, { color: theme.error, children: state.errors.password }), _jsx(Text, { children: `[ check ] c ${healthLine(state.hub.healthOk, healthChecking)}` })] })), state.hub.mode === "local" && (_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) => {
182
182
  setRootsText(value);
183
183
  setState((s) => setRoots(s, value));
184
184
  }, 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 })] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-tui",
3
- "version": "0.4.1",
3
+ "version": "0.9.0",
4
4
  "description": "Terminal app that shows today's kankaku work across every project on disk",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "ink": "^7.1.1",
28
- "kankaku": "^0.8.2",
28
+ "kankaku": "^0.9.0",
29
29
  "kankaku-hub": "^0.2.0",
30
30
  "react": "^19.3.0"
31
31
  },
@@ -35,5 +35,10 @@
35
35
  "ink-testing-library": "^4",
36
36
  "tsx": "^4",
37
37
  "typescript": "^5.7.0"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/soyunninja/kankaku.git",
42
+ "directory": "packages/tui"
38
43
  }
39
44
  }
@@ -1,161 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useRef, useState } from "react";
3
- import { Box, Text, useInput } from "ink";
4
- import { formatMinutes } from "../domain/today-model.js";
5
- import { SCREENS, hintsFor } from "../domain/nav-model.js";
6
- import { Layout } from "./layout.js";
7
- import { Panel } from "./components/panel.js";
8
- import { Table } from "./components/table.js";
9
- import { Sparkline } from "./components/sparkline.js";
10
- import { renderBar } from "./components/bar.js";
11
- const KEY_HINTS = [
12
- { key: "↑↓", label: "move" },
13
- { key: "enter", label: "open" },
14
- { key: "r", label: "refresh" },
15
- { key: "1-4", label: "screens" },
16
- { key: "q", label: "quit" },
17
- ];
18
- /** `HH:MM`, local time, from an ISO timestamp. */
19
- function formatTime(iso) {
20
- const date = new Date(iso);
21
- const hours = String(date.getHours()).padStart(2, "0");
22
- const minutes = String(date.getMinutes()).padStart(2, "0");
23
- return `${hours}:${minutes}`;
24
- }
25
- function formatCost(cost) {
26
- return `$${cost.toFixed(2)}`;
27
- }
28
- /** The header bar's right-hand hub status, or `undefined` when the hub is not configured. */
29
- function hubHeaderText(hub) {
30
- if (hub.status !== "ready")
31
- return undefined;
32
- const domain = hub.catalog?.url.replace(/^https?:\/\//, "");
33
- const domainPart = domain !== undefined ? `${domain} · ` : "";
34
- const syncedPart = hub.lastSyncAt !== undefined ? `synced ${formatTime(hub.lastSyncAt)}` : "no sync yet";
35
- return `hub ${hub.lastSyncOk ? "●" : "✕"} ${domainPart}${syncedPart}`;
36
- }
37
- function TodayPanel({ today, width, height }) {
38
- const cacheHitPart = today.cacheHit !== undefined ? ` cache hit ${Math.round(today.cacheHit * 100)}%` : "";
39
- return (_jsxs(Panel, { title: "Today", width: width, height: height, children: [_jsx(Text, { children: `work ${formatMinutes(today.workMs)}` }), _jsx(Text, { children: `wait ${formatMinutes(today.waitingMs)} cost ${formatCost(today.cost)}` }), _jsx(Text, { children: `tasks ${today.tasks}${cacheHitPart}` })] }));
40
- }
41
- function Last7DaysPanel({ points, width, height }) {
42
- return (_jsxs(Panel, { title: "Last 7 days", width: width, height: height, children: [_jsxs(Text, { children: ["work ", _jsx(Sparkline, { values: points.map((point) => point.workMs) }), " cost ", _jsx(Sparkline, { values: points.map((point) => point.cost) })] }), _jsx(Text, { dimColor: true, children: points.map((point) => point.weekday).join(" ") })] }));
43
- }
44
- /** Combined width of every fixed-width column but `project`, plus their separating spaces, the row's own `› `/` ` marker prefix, and the panel's own left/right border columns. */
45
- const PROJECT_FIXED_COLUMNS_WIDTH = 8 + 1 + 7 + 1 + 12 + 1 + 2 + 2;
46
- /** Floor for the `project` column so a very narrow table still shows a sliver of the name instead of collapsing to nothing. */
47
- const MIN_PROJECT_NAME_WIDTH = 4;
48
- /**
49
- * `work`/`cost`/`share` are fixed-width; `project` absorbs whatever width
50
- * is left in `panelWidth` (the panel's own outer width, including its
51
- * border columns) so the row's total text never exceeds the panel's
52
- * actual content width — which would otherwise wrap inside the
53
- * fixed-height, `overflow: hidden` panel and corrupt the scrolling
54
- * window (see the matching fix in `tasks-screen.tsx`).
55
- */
56
- function projectColumns(panelWidth) {
57
- const nameWidth = Math.max(panelWidth - PROJECT_FIXED_COLUMNS_WIDTH, MIN_PROJECT_NAME_WIDTH);
58
- return [
59
- { key: "name", header: "project", width: nameWidth },
60
- { key: "work", header: "work", width: 8, align: "right" },
61
- { key: "cost", header: "cost", width: 7, align: "right" },
62
- { key: "share", header: "share", width: 12 },
63
- ];
64
- }
65
- function projectCell(row, key) {
66
- switch (key) {
67
- case "name":
68
- return row.name;
69
- case "work":
70
- return formatMinutes(row.workMs);
71
- case "cost":
72
- return formatCost(row.cost);
73
- case "share":
74
- return renderBar(row.share, 1, 10);
75
- default:
76
- return "";
77
- }
78
- }
79
- function ProjectsPanel({ rows, selectedIndex, width, height, maxRows, active }) {
80
- return (_jsx(Panel, { title: "Projects", width: width, height: height, active: active, children: _jsx(Table, { columns: projectColumns(width), rows: rows, rowKey: (row) => row.name, cell: projectCell, selectedIndex: selectedIndex, emptyText: "no work recorded today", maxRows: maxRows }) }));
81
- }
82
- function HubPanel({ hub, width, height }) {
83
- return (_jsx(Panel, { title: "Hub", width: width, height: height, children: hub.status === "unavailable" ? (_jsx(Text, { dimColor: true, children: "hub not configured" })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: `pending ${hub.pending} · stale ${hub.staleOutsideWindow}` }), _jsx(Text, { children: `last sync ${hub.lastSyncOk ? "ok" : "error"}${hub.lastSyncAt !== undefined ? ` ${formatTime(hub.lastSyncAt)}` : ""}` }), hub.catalog !== undefined && _jsx(Text, { children: `catalog ${hub.catalog.clientCount} clients · ${hub.catalog.projectCount} projects` })] })) }));
84
- }
85
- /** Below this main-content width, the two-column grid collapses to one column, stacking every panel. */
86
- const GRID_BREAKPOINT = 70;
87
- /** Rows the Today card's panel takes: 1 top border + 3 content lines + 1 bottom border. */
88
- const TODAY_PANEL_ROWS = 5;
89
- /** Rows the Last 7 days panel takes: 1 top border + 2 content lines + 1 bottom border. */
90
- const LAST7_PANEL_ROWS = 4;
91
- /** Rows the Hub panel takes at most: 1 top border + up to 3 content lines + 1 bottom border. */
92
- const HUB_PANEL_ROWS = 5;
93
- /** Rows the Projects panel's own chrome (border + table header) takes outside its data rows. */
94
- const TABLE_CHROME_ROWS = 3;
95
- /** How many data rows the Projects table can show for a given `mainHeight`, depending on whether the grid is two columns (`grid`) or stacked to one (`stacked`). */
96
- function projectsMaxRows(mainHeight, layout) {
97
- const fixedRows = layout === "grid" ? TODAY_PANEL_ROWS : TODAY_PANEL_ROWS + LAST7_PANEL_ROWS + HUB_PANEL_ROWS;
98
- return Math.max(mainHeight - fixedRows - TABLE_CHROME_ROWS, 1);
99
- }
100
- /**
101
- * The Projects panel's own explicit height for a given `mainHeight`/`layout`
102
- * — `TABLE_CHROME_ROWS` plus however many data rows `projectsMaxRows`
103
- * allows, so the panel itself never grows past what its own table renders
104
- * (see the layout-stability rule this screen's panels all follow).
105
- */
106
- function projectsPanelHeight(mainHeight, layout) {
107
- return TABLE_CHROME_ROWS + projectsMaxRows(mainHeight, layout);
108
- }
109
- function DashboardGrid({ model, selectedIndex, mainWidth, mainHeight, focused, }) {
110
- if (mainWidth < GRID_BREAKPOINT) {
111
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TodayPanel, { today: model.today, width: mainWidth, height: TODAY_PANEL_ROWS }), _jsx(Last7DaysPanel, { points: model.last7Days, width: mainWidth, height: LAST7_PANEL_ROWS }), _jsx(ProjectsPanel, { rows: model.projects, selectedIndex: selectedIndex, width: mainWidth, height: projectsPanelHeight(mainHeight, "stacked"), maxRows: projectsMaxRows(mainHeight, "stacked"), active: focused }), _jsx(HubPanel, { hub: model.hub, width: mainWidth, height: HUB_PANEL_ROWS })] }));
112
- }
113
- const leftWidth = Math.floor(mainWidth * 0.55);
114
- const rightWidth = Math.max(mainWidth - leftWidth - 1, 1);
115
- return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", width: leftWidth, children: [_jsx(TodayPanel, { today: model.today, width: leftWidth, height: TODAY_PANEL_ROWS }), _jsx(ProjectsPanel, { rows: model.projects, selectedIndex: selectedIndex, width: leftWidth, height: projectsPanelHeight(mainHeight, "grid"), maxRows: projectsMaxRows(mainHeight, "grid"), active: focused })] }), _jsx(Box, { width: 1 }), _jsxs(Box, { flexDirection: "column", width: rightWidth, children: [_jsx(Last7DaysPanel, { points: model.last7Days, width: rightWidth, height: LAST7_PANEL_ROWS }), _jsx(HubPanel, { hub: model.hub, width: rightWidth, height: HUB_PANEL_ROWS })] })] }));
116
- }
117
- /**
118
- * The Today screen: a dashboard (Today card, Last 7 days sparklines,
119
- * Projects table with share bars, Hub card) inside the shared
120
- * header/sidebar/footer frame. `r` reloads through `load`; `↑↓` move the
121
- * Projects selection; `enter` opens the selected project in Tasks via
122
- * `onOpenProject`. Never writes anything to disk.
123
- */
124
- export function TodayScreen({ load, roots, version, columns, rows, focused = true, onOpenProject }) {
125
- const [model, setModel] = useState(load);
126
- const [selected, setSelected] = useState(0);
127
- const maxRowsRef = useRef(0);
128
- useInput((input, key) => {
129
- const lastIndex = Math.max(model.projects.length - 1, 0);
130
- if (input === "r") {
131
- setModel(load());
132
- }
133
- else if (key.downArrow) {
134
- setSelected((index) => Math.min(index + 1, lastIndex));
135
- }
136
- else if (key.upArrow) {
137
- setSelected((index) => Math.max(index - 1, 0));
138
- }
139
- else if (key.pageDown) {
140
- setSelected((index) => Math.min(index + Math.max(maxRowsRef.current, 1), lastIndex));
141
- }
142
- else if (key.pageUp) {
143
- setSelected((index) => Math.max(index - Math.max(maxRowsRef.current, 1), 0));
144
- }
145
- else if (key.home) {
146
- setSelected(0);
147
- }
148
- else if (key.end) {
149
- setSelected(lastIndex);
150
- }
151
- else if (key.return) {
152
- const project = model.projects[selected];
153
- if (project)
154
- onOpenProject?.(project.name);
155
- }
156
- }, { isActive: focused });
157
- return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, headerRight: hubHeaderText(model.hub), sidebarItems: SCREENS, activeId: "today", sidebarStats: [`roots ${roots.length}`, `projects ${model.projects.length}`], keyHints: hintsFor("today", focused ? "main" : "sidebar", KEY_HINTS), focus: focused ? "main" : "sidebar", children: ({ mainWidth, mainHeight }) => {
158
- maxRowsRef.current = projectsMaxRows(mainHeight, mainWidth < GRID_BREAKPOINT ? "stacked" : "grid");
159
- return _jsx(DashboardGrid, { model: model, selectedIndex: selected, mainWidth: mainWidth, mainHeight: mainHeight, focused: focused });
160
- } }));
161
- }