kankaku-tui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
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 { QUICK_ACTIONS, formatQuickActionLines } from "../domain/quick-actions.js";
7
+ import { Layout } from "./layout.js";
8
+ import { Panel } from "./components/panel.js";
9
+ import { Table } from "./components/table.js";
10
+ import { Sparkline } from "./components/sparkline.js";
11
+ import { renderBar } from "./components/bar.js";
12
+ import { useTheme } from "./theme.js";
13
+ const KEY_HINTS = [
14
+ { key: "↑↓", label: "move" },
15
+ { key: "enter", label: "open" },
16
+ { key: "r", label: "refresh" },
17
+ { key: "1-4", label: "screens" },
18
+ { key: "q", label: "quit" },
19
+ ];
20
+ const HUB_UNAVAILABLE_REASON = "hub not configured (~/.kankaku/credentials.json)";
21
+ /** `HH:MM`, local time, from an ISO timestamp. */
22
+ function formatTime(iso) {
23
+ const date = new Date(iso);
24
+ const hours = String(date.getHours()).padStart(2, "0");
25
+ const minutes = String(date.getMinutes()).padStart(2, "0");
26
+ return `${hours}:${minutes}`;
27
+ }
28
+ function formatCost(cost) {
29
+ return `$${cost.toFixed(2)}`;
30
+ }
31
+ /** The header bar's right-hand hub status, or `undefined` when the hub is not configured. */
32
+ function hubHeaderText(hub) {
33
+ if (hub.status !== "ready")
34
+ return undefined;
35
+ const domain = hub.catalog?.url.replace(/^https?:\/\//, "");
36
+ const domainPart = domain !== undefined ? `${domain} · ` : "";
37
+ const syncedPart = hub.lastSyncAt !== undefined ? `synced ${formatTime(hub.lastSyncAt)}` : "no sync yet";
38
+ return `hub ${hub.lastSyncOk ? "●" : "✕"} ${domainPart}${syncedPart}`;
39
+ }
40
+ function TodayPanel({ today, width, height }) {
41
+ const cacheHitPart = today.cacheHit !== undefined ? ` cache hit ${Math.round(today.cacheHit * 100)}%` : "";
42
+ 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}` })] }));
43
+ }
44
+ function Last7DaysPanel({ points, width, height }) {
45
+ 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(" ") })] }));
46
+ }
47
+ /** 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. */
48
+ const PROJECT_FIXED_COLUMNS_WIDTH = 8 + 1 + 7 + 1 + 12 + 1 + 2 + 2;
49
+ /** Floor for the `project` column so a very narrow table still shows a sliver of the name instead of collapsing to nothing. */
50
+ const MIN_PROJECT_NAME_WIDTH = 4;
51
+ /**
52
+ * `work`/`cost`/`share` are fixed-width; `project` absorbs whatever width
53
+ * is left in `panelWidth` (the panel's own outer width, including its
54
+ * border columns) so the row's total text never exceeds the panel's
55
+ * actual content width — which would otherwise wrap inside the
56
+ * fixed-height, `overflow: hidden` panel and corrupt the scrolling
57
+ * window (see the matching fix in `tasks-screen.tsx`).
58
+ */
59
+ function projectColumns(panelWidth) {
60
+ const nameWidth = Math.max(panelWidth - PROJECT_FIXED_COLUMNS_WIDTH, MIN_PROJECT_NAME_WIDTH);
61
+ return [
62
+ { key: "name", header: "project", width: nameWidth },
63
+ { key: "work", header: "work", width: 8, align: "right" },
64
+ { key: "cost", header: "cost", width: 7, align: "right" },
65
+ { key: "share", header: "share", width: 12 },
66
+ ];
67
+ }
68
+ function projectCell(row, key) {
69
+ switch (key) {
70
+ case "name":
71
+ return row.name;
72
+ case "work":
73
+ return formatMinutes(row.workMs);
74
+ case "cost":
75
+ return formatCost(row.cost);
76
+ case "share":
77
+ return renderBar(row.share, 1, 10);
78
+ default:
79
+ return "";
80
+ }
81
+ }
82
+ function ProjectsPanel({ rows, selectedIndex, width, height, maxRows, active }) {
83
+ 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 }) }));
84
+ }
85
+ function HubPanel({ hub, width, height }) {
86
+ 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` })] })) }));
87
+ }
88
+ /** Rows the Quick actions panel's own chrome takes outside its content: 1 top border + 1 bottom border. */
89
+ const QUICK_ACTIONS_CHROME_ROWS = 2;
90
+ /** Rows the Quick actions panel takes: its chrome, one line per `QUICK_ACTIONS` entry, plus one status line. */
91
+ const QUICK_ACTIONS_PANEL_ROWS = QUICK_ACTIONS_CHROME_ROWS + QUICK_ACTIONS.length + 1;
92
+ /**
93
+ * The `[ Quick actions ]` panel: `QUICK_ACTIONS` listed one per line (key
94
+ * in the accent colour, label muted — the same convention as the footer
95
+ * `KeyHints`), followed by one status line from `domain/quick-actions.ts#formatQuickActionLines`
96
+ * (always exactly one line, so this panel's height never changes with the
97
+ * message).
98
+ */
99
+ function QuickActionsPanel({ state, width, height, active }) {
100
+ const theme = useTheme();
101
+ const [statusLine] = formatQuickActionLines(Math.max(width - 2, 1), state);
102
+ return (_jsxs(Panel, { title: "Quick actions", width: width, height: height, active: active, children: [QUICK_ACTIONS.map((action) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: action.key }), _jsx(Text, { color: theme.muted, children: ` ${action.label}` })] }, action.key))), _jsx(Text, { dimColor: state.status === "busy" || state.status === "unavailable", color: state.status === "done" && statusLine.startsWith("error:") ? theme.error : undefined, children: statusLine })] }));
103
+ }
104
+ /** Below this main-content width, the two-column grid collapses to one column, stacking every panel. */
105
+ const GRID_BREAKPOINT = 70;
106
+ /** Rows the Today card's panel takes: 1 top border + 3 content lines + 1 bottom border. */
107
+ const TODAY_PANEL_ROWS = 5;
108
+ /** Rows the Last 7 days panel takes: 1 top border + 2 content lines + 1 bottom border. */
109
+ const LAST7_PANEL_ROWS = 4;
110
+ /** Rows the Hub panel takes at most: 1 top border + up to 3 content lines + 1 bottom border. */
111
+ const HUB_PANEL_ROWS = 5;
112
+ /** Rows the Projects panel's own chrome (border + table header) takes outside its data rows. */
113
+ const TABLE_CHROME_ROWS = 3;
114
+ /** 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`). */
115
+ function projectsMaxRows(mainHeight, layout) {
116
+ const fixedRows = layout === "grid" ? TODAY_PANEL_ROWS : TODAY_PANEL_ROWS + LAST7_PANEL_ROWS + HUB_PANEL_ROWS + QUICK_ACTIONS_PANEL_ROWS;
117
+ return Math.max(mainHeight - fixedRows - TABLE_CHROME_ROWS, 1);
118
+ }
119
+ /**
120
+ * The Projects panel's own explicit height for a given `mainHeight`/`layout`
121
+ * — `TABLE_CHROME_ROWS` plus however many data rows `projectsMaxRows`
122
+ * allows, so the panel itself never grows past what its own table renders
123
+ * (see the layout-stability rule this screen's panels all follow).
124
+ */
125
+ function projectsPanelHeight(mainHeight, layout) {
126
+ return TABLE_CHROME_ROWS + projectsMaxRows(mainHeight, layout);
127
+ }
128
+ function DashboardGrid({ model, selectedIndex, mainWidth, mainHeight, focused, actionState, }) {
129
+ if (mainWidth < GRID_BREAKPOINT) {
130
+ 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(QuickActionsPanel, { state: actionState, width: mainWidth, height: QUICK_ACTIONS_PANEL_ROWS, active: false }), _jsx(HubPanel, { hub: model.hub, width: mainWidth, height: HUB_PANEL_ROWS })] }));
131
+ }
132
+ const leftWidth = Math.floor(mainWidth * 0.55);
133
+ const rightWidth = Math.max(mainWidth - leftWidth - 1, 1);
134
+ 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 }), _jsx(QuickActionsPanel, { state: actionState, width: rightWidth, height: QUICK_ACTIONS_PANEL_ROWS, active: false })] })] }));
135
+ }
136
+ /** The Quick actions panel's initial state: idle when the hub is configured, `unavailable` (with its reason) otherwise. */
137
+ function initialActionState(hubAvailable) {
138
+ return hubAvailable ? { status: "idle" } : { status: "unavailable", reason: HUB_UNAVAILABLE_REASON };
139
+ }
140
+ /**
141
+ * The Dashboard screen: a Today card (work/wait/cost/tasks/cache hit), a
142
+ * Last 7 days card (work and cost sparklines with weekday labels), a
143
+ * Projects table (work, cost and a share bar per project), a Hub card
144
+ * (pending/stale, last sync time, catalog summary) and a Quick actions
145
+ * panel (`c` refresh the catalog, `s` sync every project, `S` full-sync
146
+ * every project, `r` reload) inside the shared header/sidebar/footer
147
+ * frame. `r` reloads through `load`; `↑↓` move the Projects selection;
148
+ * `enter` opens the selected project in Tasks via `onOpenProject`. `c`/
149
+ * `s`/`S` run through `actions` (never reimplementing kankaku's own
150
+ * catalog/sync adapters); only one quick action runs at a time, and the
151
+ * dashboard model reloads once it settles so the Hub card and Projects
152
+ * table reflect the new state.
153
+ */
154
+ export function DashboardScreen({ load, actions, roots, version, columns, rows, focused = true, onOpenProject }) {
155
+ const [model, setModel] = useState(load);
156
+ const [selected, setSelected] = useState(0);
157
+ const [actionState, setActionState] = useState(() => initialActionState(actions.hubAvailable));
158
+ const maxRowsRef = useRef(0);
159
+ const busyRef = useRef(false);
160
+ const runQuickAction = (key) => {
161
+ if (busyRef.current)
162
+ return;
163
+ if (key === "r") {
164
+ setModel(load());
165
+ return;
166
+ }
167
+ if (!actions.hubAvailable)
168
+ return;
169
+ busyRef.current = true;
170
+ setActionState({ status: "busy", key });
171
+ const run = key === "c" ? actions.refreshCatalog() : actions.syncAll({ full: key === "S" });
172
+ run
173
+ .then((message) => {
174
+ setActionState({ status: "done", key, message });
175
+ setModel(load());
176
+ })
177
+ .catch((error) => {
178
+ setActionState({ status: "done", key, message: `error: ${error instanceof Error ? error.message : String(error)}` });
179
+ })
180
+ .finally(() => {
181
+ busyRef.current = false;
182
+ });
183
+ };
184
+ useInput((input, key) => {
185
+ const lastIndex = Math.max(model.projects.length - 1, 0);
186
+ if (input === "c" || input === "s" || input === "S" || input === "r") {
187
+ runQuickAction(input);
188
+ }
189
+ else if (key.downArrow) {
190
+ setSelected((index) => Math.min(index + 1, lastIndex));
191
+ }
192
+ else if (key.upArrow) {
193
+ setSelected((index) => Math.max(index - 1, 0));
194
+ }
195
+ else if (key.pageDown) {
196
+ setSelected((index) => Math.min(index + Math.max(maxRowsRef.current, 1), lastIndex));
197
+ }
198
+ else if (key.pageUp) {
199
+ setSelected((index) => Math.max(index - Math.max(maxRowsRef.current, 1), 0));
200
+ }
201
+ else if (key.home) {
202
+ setSelected(0);
203
+ }
204
+ else if (key.end) {
205
+ setSelected(lastIndex);
206
+ }
207
+ else if (key.return) {
208
+ const project = model.projects[selected];
209
+ if (project)
210
+ onOpenProject?.(project.name);
211
+ }
212
+ }, { isActive: focused });
213
+ return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, headerRight: hubHeaderText(model.hub), sidebarItems: SCREENS, activeId: "dashboard", sidebarStats: [`roots ${roots.length}`, `projects ${model.projects.length}`], keyHints: hintsFor("dashboard", focused ? "main" : "sidebar", KEY_HINTS), focus: focused ? "main" : "sidebar", children: ({ mainWidth, mainHeight }) => {
214
+ maxRowsRef.current = projectsMaxRows(mainHeight, mainWidth < GRID_BREAKPOINT ? "stacked" : "grid");
215
+ return _jsx(DashboardGrid, { model: model, selectedIndex: selected, mainWidth: mainWidth, mainHeight: mainHeight, focused: focused, actionState: actionState });
216
+ } }));
217
+ }
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useStdout } from "ink";
3
+ import { HeaderBar } from "./components/header-bar.js";
4
+ import { Sidebar } from "./components/sidebar.js";
5
+ import { KeyHints } from "./components/key-hints.js";
6
+ import { useTheme } from "./theme.js";
7
+ /** Sidebar width in the wide (side-by-side) layout. */
8
+ const SIDEBAR_WIDTH = 16;
9
+ /** Below this width, the main content stacks under a full-width sidebar instead of sitting beside it. */
10
+ const STACK_BREAKPOINT = 100;
11
+ /** Below this width, the sidebar collapses to a one-line tab strip. */
12
+ const COLLAPSE_BREAKPOINT = 70;
13
+ /** Rows used by the header line. */
14
+ const HEADER_ROWS = 1;
15
+ /** Rows used by the footer key-hints line. */
16
+ const FOOTER_ROWS = 1;
17
+ /** Rows used by the collapsed mode's one-line tab strip. */
18
+ const TAB_STRIP_ROWS = 1;
19
+ /** Fallback terminal height when neither `rows` nor `useStdout().rows` is available (e.g. a non-TTY test double). */
20
+ const DEFAULT_ROWS = 24;
21
+ /** Total rows the {@link Sidebar} block occupies in stacked mode: nav items, the divider, the stats lines, and its own top/bottom border. */
22
+ function sidebarBlockRows(itemCount, statsCount) {
23
+ return itemCount + 1 + statsCount + 2;
24
+ }
25
+ function TabStrip({ items, activeId }) {
26
+ const theme = useTheme();
27
+ return (_jsx(Text, { children: items.map((item, index) => (_jsxs(Text, { children: [index > 0 ? " · " : "", _jsx(Text, { color: item.id === activeId ? theme.accent : theme.text, bold: item.id === activeId, children: item.id === activeId ? `[${item.label}]` : item.label })] }, item.id))) }));
28
+ }
29
+ /**
30
+ * The app frame: header line, sidebar + main content, footer key hints,
31
+ * filling the whole terminal height (`rows`, from `useStdout()` when not
32
+ * given). Responsive to `columns` (from `useStdout()` when not given, so
33
+ * tests can force a width): at 100+ columns the sidebar sits beside main;
34
+ * at 70-99 columns main stacks under a full-width sidebar; below 70
35
+ * columns the sidebar collapses to a one-line tab strip. The middle row
36
+ * (everything between the header and the footer) grows to fill whatever
37
+ * height is left, `useStdout()` re-renders this component on a terminal
38
+ * resize, and `children` is a render function so a screen can size its
39
+ * own panels and scrolling lists to the actual `mainWidth`/`mainHeight`
40
+ * in whichever mode is active.
41
+ */
42
+ export function Layout({ columns, rows, headerLeft, headerRight, sidebarItems, activeId, sidebarStats, keyHints, focus = "sidebar", children }) {
43
+ const { stdout } = useStdout();
44
+ const width = columns ?? stdout?.columns ?? 80;
45
+ const height = rows ?? stdout?.rows ?? DEFAULT_ROWS;
46
+ const sidebarFocused = focus === "sidebar";
47
+ const mode = width < COLLAPSE_BREAKPOINT ? "collapsed" : width < STACK_BREAKPOINT ? "stacked" : "wide";
48
+ const mainWidth = mode === "wide" ? Math.max(width - SIDEBAR_WIDTH, 1) : width;
49
+ const middleRows = Math.max(height - HEADER_ROWS - FOOTER_ROWS, 0);
50
+ const chromeRows = mode === "collapsed" ? TAB_STRIP_ROWS : mode === "stacked" ? sidebarBlockRows(sidebarItems.length, sidebarStats.length) : 0;
51
+ const mainHeight = Math.max(middleRows - chromeRows, 0);
52
+ 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
+ }
@@ -0,0 +1,127 @@
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 { SCREENS, hintsFor } from "../domain/nav-model.js";
5
+ import { Layout } from "./layout.js";
6
+ import { Panel } from "./components/panel.js";
7
+ import { windowRows } from "../domain/list-window.js";
8
+ import { useTheme } from "./theme.js";
9
+ /** Fixed card width in the grid, including its border. */
10
+ const CARD_WIDTH = 32;
11
+ /** Estimated card height (border + up to 4 content lines: pending, synced/never, an error or result message, and a busy note), used to budget how many full rows of cards fit `mainHeight`. */
12
+ const CARD_HEIGHT_ESTIMATE = 6;
13
+ const KEY_HINTS = [
14
+ { key: "↑↓", label: "select" },
15
+ { key: "s", label: "sync" },
16
+ { key: "f", label: "full sync" },
17
+ { key: "S", label: "sync all" },
18
+ { key: "1-4", label: "screens" },
19
+ { key: "q", label: "quit" },
20
+ ];
21
+ function Card({ row, active, message, busy, width }) {
22
+ const staleOutsidePart = row.staleOutsideWindow > 0 ? ` stale ${row.staleOutsideWindow}` : "";
23
+ return (_jsxs(Panel, { title: `${active ? "› " : " "}${row.name}`, active: active, width: width, height: CARD_HEIGHT_ESTIMATE, children: [_jsx(Text, { children: `pending ${row.pending}${staleOutsidePart}` }), _jsx(Text, { dimColor: true, children: row.syncedThrough !== undefined ? `synced through ${row.syncedThrough}` : "never synced" }), row.lastError !== undefined && _jsx(Text, { color: "red", children: `error: ${row.lastError.message}` }), message !== undefined && _jsx(Text, { children: `→ ${message}` }), busy && _jsx(Text, { dimColor: true, children: "syncing\u2026" })] }));
24
+ }
25
+ /**
26
+ * The Sync screen: one `[ project ]` card per project, in a wrapping grid,
27
+ * from `computeSyncStatus` (`domain/sync-model.ts`, never reimplemented
28
+ * here). `↑↓` move the selection; `s` syncs the selected project, `f`
29
+ * full-syncs it, `S` syncs every project — all through
30
+ * `adapters/hub.ts#syncProject`. Each action's result shows inline in its
31
+ * card while it runs and once it settles. Without hub credentials shows a
32
+ * one-line note instead. Writes only through kankaku's own sync adapters.
33
+ */
34
+ export function SyncScreen({ load, syncOne, syncAll, roots, version, columns, rows, focused = true }) {
35
+ const theme = useTheme();
36
+ const [model, setModel] = useState(load);
37
+ const [selected, setSelected] = useState(0);
38
+ const [messages, setMessages] = useState({});
39
+ const [busy, setBusy] = useState(new Set());
40
+ const selectedRef = useRef(0);
41
+ const maxVisibleRef = useRef(0);
42
+ const startRef = useRef(0);
43
+ const projectRows = model.status === "ready" ? model.rows : [];
44
+ const runOne = (row, full) => {
45
+ setBusy((prev) => new Set(prev).add(row.name));
46
+ void syncOne(row, { full }).then((result) => {
47
+ setMessages((prev) => ({ ...prev, [row.name]: result.message }));
48
+ setBusy((prev) => {
49
+ const next = new Set(prev);
50
+ next.delete(row.name);
51
+ return next;
52
+ });
53
+ });
54
+ };
55
+ useInput((input, key) => {
56
+ if (model.status !== "ready")
57
+ return;
58
+ const lastIndex = Math.max(projectRows.length - 1, 0);
59
+ if (key.downArrow) {
60
+ selectedRef.current = Math.min(selectedRef.current + 1, lastIndex);
61
+ setSelected(selectedRef.current);
62
+ }
63
+ else if (key.upArrow) {
64
+ selectedRef.current = Math.max(selectedRef.current - 1, 0);
65
+ setSelected(selectedRef.current);
66
+ }
67
+ else if (key.pageDown) {
68
+ selectedRef.current = Math.min(selectedRef.current + Math.max(maxVisibleRef.current, 1), lastIndex);
69
+ setSelected(selectedRef.current);
70
+ }
71
+ else if (key.pageUp) {
72
+ selectedRef.current = Math.max(selectedRef.current - Math.max(maxVisibleRef.current, 1), 0);
73
+ setSelected(selectedRef.current);
74
+ }
75
+ else if (key.home) {
76
+ selectedRef.current = 0;
77
+ setSelected(0);
78
+ }
79
+ else if (key.end) {
80
+ selectedRef.current = lastIndex;
81
+ setSelected(lastIndex);
82
+ }
83
+ else if (input === "s") {
84
+ const row = projectRows[selectedRef.current];
85
+ if (row)
86
+ runOne(row, false);
87
+ }
88
+ else if (input === "f") {
89
+ const row = projectRows[selectedRef.current];
90
+ if (row)
91
+ runOne(row, true);
92
+ }
93
+ else if (input === "S") {
94
+ setBusy(new Set(projectRows.map((row) => row.name)));
95
+ void syncAll().then((results) => {
96
+ setMessages((prev) => {
97
+ const next = { ...prev };
98
+ results.forEach((result, index) => {
99
+ const row = projectRows[index];
100
+ if (row)
101
+ next[row.name] = result.message;
102
+ });
103
+ return next;
104
+ });
105
+ setBusy(new Set());
106
+ });
107
+ }
108
+ }, { isActive: focused });
109
+ if (model.status === "unavailable") {
110
+ return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, sidebarItems: SCREENS, activeId: "sync", sidebarStats: [`roots ${roots.length}`], keyHints: hintsFor("sync", focused ? "main" : "sidebar", KEY_HINTS), focus: focused ? "main" : "sidebar", children: () => _jsx(Text, { dimColor: true, children: model.reason }) }));
111
+ }
112
+ return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, sidebarItems: SCREENS, activeId: "sync", sidebarStats: [`projects ${projectRows.length}`], keyHints: hintsFor("sync", focused ? "main" : "sidebar", KEY_HINTS), focus: focused ? "main" : "sidebar", children: ({ mainWidth, mainHeight }) => {
113
+ if (projectRows.length === 0)
114
+ return _jsx(Text, { dimColor: true, children: "no projects" });
115
+ const cardsPerRow = Math.max(Math.floor(mainWidth / (CARD_WIDTH + 1)), 1);
116
+ const visibleGridRows = Math.max(Math.floor(mainHeight / CARD_HEIGHT_ESTIMATE), 1);
117
+ const maxVisible = cardsPerRow * visibleGridRows;
118
+ maxVisibleRef.current = maxVisible;
119
+ const window = windowRows(projectRows.length, selected, maxVisible, startRef.current);
120
+ startRef.current = window.start;
121
+ const visibleRows = projectRows.slice(window.start, window.end);
122
+ return (_jsxs(Box, { flexDirection: "column", children: [window.hiddenAbove > 0 && _jsx(Text, { color: theme.muted, children: `↑ ${window.hiddenAbove} more` }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", width: mainWidth, children: visibleRows.map((row, index) => {
123
+ const actualIndex = window.start + index;
124
+ return (_jsx(Box, { marginRight: 1, marginBottom: 1, children: _jsx(Card, { row: row, active: actualIndex === selected && focused, message: messages[row.name], busy: busy.has(row.name), width: Math.min(CARD_WIDTH, mainWidth) }) }, row.name));
125
+ }) }), window.hiddenBelow > 0 && _jsx(Text, { color: theme.muted, children: `↓ ${window.hiddenBelow} more` })] }));
126
+ } }));
127
+ }
@@ -0,0 +1,166 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, 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 { wrapText } from "../domain/text-wrap.js";
7
+ import { Layout } from "./layout.js";
8
+ import { Panel } from "./components/panel.js";
9
+ import { Table } from "./components/table.js";
10
+ import { useTheme } from "./theme.js";
11
+ function formatCost(cost) {
12
+ return `$${cost.toFixed(2)}`;
13
+ }
14
+ /** Combined width of every fixed-width column plus their separating spaces, the row's own `› `/` ` marker prefix, and the panel's own left/right border columns — everything the `prompt` column does not own. */
15
+ const TASK_FIXED_COLUMNS_WIDTH = 5 + 1 + 12 + 1 + 7 + 1 + 6 + 1 + 2 + 2;
16
+ /** Floor for the `prompt` column so a very narrow table still shows a sliver of text instead of collapsing to nothing. */
17
+ const MIN_PROMPT_WIDTH = 4;
18
+ /**
19
+ * `time`/`project`/`work`/`cost` are fixed-width; `prompt` absorbs
20
+ * whatever width is left in `tableWidth` (the panel's own outer width,
21
+ * including its border columns) so the row's total text never exceeds
22
+ * the panel's actual content width — which would otherwise wrap inside
23
+ * the fixed-height, `overflow: hidden` panel and silently eat rows from
24
+ * the scrolling window.
25
+ */
26
+ function taskColumns(tableWidth) {
27
+ const promptWidth = Math.max(tableWidth - TASK_FIXED_COLUMNS_WIDTH, MIN_PROMPT_WIDTH);
28
+ return [
29
+ { key: "time", header: "time", width: 5 },
30
+ { key: "project", header: "project", width: 12 },
31
+ { key: "work", header: "work", width: 7, align: "right" },
32
+ { key: "cost", header: "cost", width: 6, align: "right" },
33
+ { key: "prompt", header: "prompt", width: promptWidth },
34
+ ];
35
+ }
36
+ function taskCell(row, key) {
37
+ switch (key) {
38
+ case "time":
39
+ return row.time;
40
+ case "project":
41
+ return row.project;
42
+ case "work":
43
+ return formatMinutes(row.workMs);
44
+ case "cost":
45
+ return formatCost(row.cost);
46
+ case "prompt":
47
+ return row.prompt;
48
+ default:
49
+ return "";
50
+ }
51
+ }
52
+ /** Rows the detail panel's own chrome (top border line + the inner box's bottom border) takes outside its content rows. */
53
+ const DETAIL_CHROME_ROWS = 2;
54
+ /**
55
+ * The `[ Task ]` detail panel body: fixed-count metadata lines (client,
56
+ * project, hub task, wall/work/wait, cost, subagents — whichever apply)
57
+ * plus the prompt, word-wrapped to the panel's own inner width
58
+ * (`wrapText`, never reimplemented here). The panel's `height` is fixed by
59
+ * the caller (derived from `mainHeight`, never grown by content — see the
60
+ * layout-stability rule in AGENTS.md-equivalent project docs); when the
61
+ * wrapped prompt would need more rows than are left after the metadata
62
+ * lines, only as many prompt lines as fit are shown, followed by a single
63
+ * `muted` `… N more lines` line instead of letting Ink's own overflow
64
+ * clipping silently swallow the rest with no visible cue.
65
+ */
66
+ function DetailPanel({ row, width, height }) {
67
+ const theme = useTheme();
68
+ if (row === undefined) {
69
+ return (_jsx(Panel, { title: "Task", width: width, height: height, children: _jsx(Text, { dimColor: true, children: "no task selected" }) }));
70
+ }
71
+ const innerWidth = Math.max(width - 2, 1);
72
+ const contentRows = Math.max(height - DETAIL_CHROME_ROWS, 0);
73
+ const metaLines = [];
74
+ if (row.clientName !== undefined)
75
+ metaLines.push({ text: `client ${row.clientName}`, dim: true });
76
+ if (row.projectName !== undefined)
77
+ metaLines.push({ text: `project ${row.projectName}`, dim: true });
78
+ if (row.hubTaskTitle !== undefined)
79
+ metaLines.push({ text: `task ${row.hubTaskTitle}`, dim: true });
80
+ metaLines.push({ text: `wall ${formatMinutes(row.wallMs)} work ${formatMinutes(row.workMs)} wait ${formatMinutes(row.waitingMs)}`, dim: false });
81
+ metaLines.push({ text: `cost ${formatCost(row.cost)}${row.cacheHit !== undefined ? ` cache hit ${Math.round(row.cacheHit * 100)}%` : ""}`, dim: false });
82
+ metaLines.push({ text: `subagents ${row.subagentCount}`, dim: false });
83
+ const promptBudget = Math.max(contentRows - metaLines.length, 0);
84
+ const wrappedPrompt = wrapText(row.fullPrompt, innerWidth);
85
+ const truncated = wrappedPrompt.length > promptBudget;
86
+ const visiblePromptLines = truncated ? wrappedPrompt.slice(0, Math.max(promptBudget - 1, 0)) : wrappedPrompt.slice(0, promptBudget);
87
+ const hiddenCount = wrappedPrompt.length - visiblePromptLines.length;
88
+ 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}`)))] }) }));
90
+ }
91
+ const KEY_HINTS = [
92
+ { key: "↑↓", label: "select" },
93
+ { key: "a", label: "today/all" },
94
+ { key: "r", label: "refresh" },
95
+ { key: "esc", label: "clear filter" },
96
+ { key: "1-4", label: "screens" },
97
+ { key: "q", label: "quit" },
98
+ ];
99
+ /** Below this main-content width, the detail panel stacks under the table instead of beside it. */
100
+ const DETAIL_BREAKPOINT = 70;
101
+ /** Rows the `Panel` chrome (top border line + bottom border line) and the `Table`'s own header line take, outside its data rows. */
102
+ const TABLE_CHROME_ROWS = 3;
103
+ /**
104
+ * The Tasks screen: a table of tasks (via `domain/tasks-model.ts`, never
105
+ * reimplemented here) with a `[ Task ]` detail panel for the selected row.
106
+ * `a` toggles today/all, `r` reloads, `↑↓` move the selection. When
107
+ * `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.
110
+ */
111
+ export function TasksScreen({ load, roots, version, columns, rows, focused = true, projectFilter, onClearFilter }) {
112
+ const allRef = useRef(false);
113
+ const [all, setAll] = useState(false);
114
+ const [model, setModel] = useState(() => load({ all: false }));
115
+ const [selected, setSelected] = useState(0);
116
+ const visibleRows = useMemo(() => (projectFilter !== undefined ? model.rows.filter((row) => row.project === projectFilter) : model.rows), [model.rows, projectFilter]);
117
+ const maxRowsRef = useRef(0);
118
+ useInput((input, key) => {
119
+ const lastIndex = Math.max(visibleRows.length - 1, 0);
120
+ if (input === "a") {
121
+ const nextAll = !allRef.current;
122
+ allRef.current = nextAll;
123
+ setAll(nextAll);
124
+ setModel(load({ all: nextAll }));
125
+ setSelected(0);
126
+ }
127
+ else if (input === "r") {
128
+ setModel(load({ all: allRef.current }));
129
+ }
130
+ else if (key.downArrow) {
131
+ setSelected((index) => Math.min(index + 1, lastIndex));
132
+ }
133
+ else if (key.upArrow) {
134
+ setSelected((index) => Math.max(index - 1, 0));
135
+ }
136
+ else if (key.pageDown) {
137
+ setSelected((index) => Math.min(index + Math.max(maxRowsRef.current, 1), lastIndex));
138
+ }
139
+ else if (key.pageUp) {
140
+ setSelected((index) => Math.max(index - Math.max(maxRowsRef.current, 1), 0));
141
+ }
142
+ else if (key.home) {
143
+ setSelected(0);
144
+ }
145
+ else if (key.end) {
146
+ setSelected(lastIndex);
147
+ }
148
+ else if (key.escape) {
149
+ onClearFilter?.();
150
+ }
151
+ }, { 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 }) => {
153
+ const wide = mainWidth >= DETAIL_BREAKPOINT;
154
+ const tableWidth = wide ? Math.floor(mainWidth * 0.62) : mainWidth;
155
+ const detailWidth = wide ? Math.max(mainWidth - tableWidth - 1, 1) : mainWidth;
156
+ const selectedRow = visibleRows[Math.min(selected, Math.max(visibleRows.length - 1, 0))];
157
+ // Stacked mode splits the height between the table and the detail panel below it, mirroring the width split used in wide mode.
158
+ const tableHeight = wide ? mainHeight : Math.max(Math.floor(mainHeight * 0.62), TABLE_CHROME_ROWS + 1);
159
+ const detailHeight = wide ? mainHeight : Math.max(mainHeight - tableHeight, DETAIL_CHROME_ROWS + 1);
160
+ const maxRows = Math.max(tableHeight - TABLE_CHROME_ROWS, 1);
161
+ maxRowsRef.current = maxRows;
162
+ 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 });
164
+ return wide ? (_jsxs(Box, { flexDirection: "row", children: [table, _jsx(Box, { width: 1 }), detail] })) : (_jsxs(Box, { flexDirection: "column", children: [table, detail] }));
165
+ } }));
166
+ }
@@ -0,0 +1,81 @@
1
+ import { createContext, createElement, useContext } from "react";
2
+ /**
3
+ * The three built-in presets: resolved hex values copied from
4
+ * [gentle-pi](https://github.com/Gentleman-Programming/gentle-pi)'s own
5
+ * themes (MIT), so this TUI's palette matches the owner's pi panel instead
6
+ * of an unrelated default. `gentle-pi/themes/GentlemanSexy.json`.
7
+ */
8
+ export const GENTLEMAN_SEXY_THEME = {
9
+ accent: "#F43888",
10
+ border: "#563040",
11
+ borderActive: "#FF4F9A",
12
+ text: "#F6EFF3",
13
+ muted: "#A78E9B",
14
+ dim: "#76616B",
15
+ ok: "#D2CBD0",
16
+ warn: "#F2B86D",
17
+ error: "#FF718F",
18
+ selectionBg: "#28121E",
19
+ };
20
+ /** `gentle-pi/themes/GentlemanCute.json` (MIT) — resolved hex values. */
21
+ export const GENTLEMAN_CUTE_THEME = {
22
+ accent: "#F095C8",
23
+ border: "#563040",
24
+ borderActive: "#FFB1DD",
25
+ text: "#F6EFF3",
26
+ muted: "#A78E9B",
27
+ dim: "#76616B",
28
+ ok: "#B4E7C7",
29
+ warn: "#F2B86D",
30
+ error: "#FF718F",
31
+ selectionBg: "#28121E",
32
+ };
33
+ /** `gentle-pi/themes/Gentle.json` (MIT) — resolved hex values. */
34
+ export const GENTLE_THEME = {
35
+ accent: "#7FB4CA",
36
+ border: "#313342",
37
+ borderActive: "#7FB4CA",
38
+ text: "#F3F6F9",
39
+ muted: "#5C6170",
40
+ dim: "#5C6170",
41
+ ok: "#B7CC85",
42
+ warn: "#DEBA87",
43
+ error: "#CB7C94",
44
+ selectionBg: "#232A40",
45
+ };
46
+ /** Every built-in preset's selectable name, in the order `resolveTheme`'s usage error lists them. */
47
+ export const THEME_NAMES = ["gentleman-sexy", "gentleman-cute", "gentle"];
48
+ /** Every built-in preset, keyed by its selectable `ThemeName`. */
49
+ export const THEMES = {
50
+ "gentleman-sexy": GENTLEMAN_SEXY_THEME,
51
+ "gentleman-cute": GENTLEMAN_CUTE_THEME,
52
+ gentle: GENTLE_THEME,
53
+ };
54
+ /** The default preset when no `--theme`/`KANKAKU_TUI_THEME` is given: the owner's own pi theme. */
55
+ export const DEFAULT_THEME = GENTLEMAN_SEXY_THEME;
56
+ /**
57
+ * Resolve a `--theme`/`KANKAKU_TUI_THEME` name (see `cli.tsx`) to its
58
+ * `Theme` preset. An unknown name fails with a usage error listing every
59
+ * valid `THEME_NAMES` entry, rather than silently falling back to a
60
+ * default — pure, so it's testable without rendering anything.
61
+ */
62
+ export function resolveTheme(name) {
63
+ const theme = THEMES[name];
64
+ if (theme === undefined) {
65
+ return { ok: false, reason: `unknown theme "${name}"; choose one of: ${THEME_NAMES.join(", ")}` };
66
+ }
67
+ return { ok: true, theme };
68
+ }
69
+ const ThemeContext = createContext(DEFAULT_THEME);
70
+ /**
71
+ * Provides a {@link Theme} to its subtree via context. Written with
72
+ * `createElement` (not JSX) so this file can stay a plain `.ts` module, as
73
+ * named in `odd/tasks/visual-system.md`.
74
+ */
75
+ export function ThemeProvider({ theme = DEFAULT_THEME, children }) {
76
+ return createElement(ThemeContext.Provider, { value: theme }, children);
77
+ }
78
+ /** The current {@link Theme}: the nearest {@link ThemeProvider}'s value, or {@link DEFAULT_THEME} outside one. */
79
+ export function useTheme() {
80
+ return useContext(ThemeContext);
81
+ }