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,51 @@
1
+ import { buildTasks, cacheHitRatio, localDay } from "kankaku/domain";
2
+ /** Maximum visible width of a task prompt before it is truncated with an ellipsis marker. */
3
+ const PROMPT_DISPLAY_LIMIT = 40;
4
+ function formatTime(iso) {
5
+ const date = new Date(iso);
6
+ const hours = String(date.getHours()).padStart(2, "0");
7
+ const minutes = String(date.getMinutes()).padStart(2, "0");
8
+ return `${hours}:${minutes}`;
9
+ }
10
+ /** Truncate `text` to `limit` visible characters, appending `…` (counted within the limit) when it was cut. Collapses internal whitespace first, since a prompt often spans several lines. */
11
+ function truncatePrompt(text, limit) {
12
+ const collapsed = text.replace(/\s+/g, " ").trim();
13
+ return collapsed.length > limit ? `${collapsed.slice(0, limit - 1)}…` : collapsed;
14
+ }
15
+ function toRow(project, task) {
16
+ return {
17
+ id: task.id,
18
+ time: formatTime(task.startedAt),
19
+ project,
20
+ ...(task.clientName !== undefined ? { clientName: task.clientName } : {}),
21
+ ...(task.projectName !== undefined ? { projectName: task.projectName } : {}),
22
+ ...(task.hubTaskTitle !== undefined ? { hubTaskTitle: task.hubTaskTitle } : {}),
23
+ wallMs: task.wallMs,
24
+ workMs: task.workMs,
25
+ waitingMs: task.waitingMs,
26
+ cost: task.usage.cost,
27
+ ...(cacheHitRatio(task.usage) !== undefined ? { cacheHit: cacheHitRatio(task.usage) } : {}),
28
+ subagentCount: task.subagents.length,
29
+ prompt: truncatePrompt(task.prompt, PROMPT_DISPLAY_LIMIT),
30
+ fullPrompt: task.prompt,
31
+ };
32
+ }
33
+ /**
34
+ * Build one {@link TaskRow} per task (via kankaku's own `buildTasks`, never
35
+ * reimplementing its orchestrator/subagent join) across every project,
36
+ * newest first. Restricted to `options.today` (default: today) unless
37
+ * `options.all` is set.
38
+ */
39
+ export function buildTasksModel(projects, options = {}) {
40
+ const targetDay = options.all ? undefined : (options.today ?? localDay(new Date().toISOString()));
41
+ const entries = [];
42
+ for (const project of projects) {
43
+ for (const task of buildTasks(project.records)) {
44
+ if (targetDay !== undefined && localDay(task.startedAt) !== targetDay)
45
+ continue;
46
+ entries.push({ project: project.name, task });
47
+ }
48
+ }
49
+ entries.sort((a, b) => Date.parse(b.task.startedAt) - Date.parse(a.task.startedAt) || b.task.id.localeCompare(a.task.id));
50
+ return { rows: entries.map((entry) => toRow(entry.project, entry.task)) };
51
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Word-wrap `text` to at most `width` visible characters per line. Explicit
3
+ * `\n` in `text` starts a new wrap group (so a multi-line prompt keeps its
4
+ * own line breaks); a single word longer than `width` is hard-broken into
5
+ * `width`-sized chunks rather than overflowing. `width` below 1 is treated
6
+ * as 1, so this never loops forever or drops characters. Pure and
7
+ * deterministic: used by `ui/tasks-screen.tsx`'s detail panel to know how
8
+ * many lines a long prompt needs before deciding how much of it fits a
9
+ * fixed-height panel (see AGENTS.md-equivalent "the layout must never
10
+ * move" rule in `odd/tasks/`).
11
+ */
12
+ export function wrapText(text, width) {
13
+ const safeWidth = Math.max(Math.floor(width), 1);
14
+ const lines = [];
15
+ for (const paragraph of text.split("\n")) {
16
+ if (paragraph.length === 0) {
17
+ lines.push("");
18
+ continue;
19
+ }
20
+ let current = "";
21
+ for (const word of paragraph.split(" ")) {
22
+ if (word.length > safeWidth) {
23
+ if (current.length > 0) {
24
+ lines.push(current);
25
+ current = "";
26
+ }
27
+ let remaining = word;
28
+ while (remaining.length > safeWidth) {
29
+ lines.push(remaining.slice(0, safeWidth));
30
+ remaining = remaining.slice(safeWidth);
31
+ }
32
+ current = remaining;
33
+ continue;
34
+ }
35
+ const candidate = current.length === 0 ? word : `${current} ${word}`;
36
+ if (candidate.length > safeWidth) {
37
+ lines.push(current);
38
+ current = word;
39
+ }
40
+ else {
41
+ current = candidate;
42
+ }
43
+ }
44
+ lines.push(current);
45
+ }
46
+ return lines;
47
+ }
@@ -0,0 +1,75 @@
1
+ import { summarize } from "kankaku/hub";
2
+ import { cacheHitRatio } from "kankaku/domain";
3
+ /**
4
+ * Build one {@link TodayRow} per project (via kankaku's own `summarize`,
5
+ * never reimplementing its aggregation) plus a grand total, sorted by
6
+ * `workMs` descending. Projects with no tasks on the target day are
7
+ * dropped. `waitingMs` comes from the role totals (`orchestrator` +
8
+ * `subagent`), since `summarize`'s task totals do not carry it.
9
+ */
10
+ export function buildTodayRows(projects, options = {}) {
11
+ const rows = [];
12
+ let totalTasks = 0;
13
+ let totalWallMs = 0;
14
+ let totalWorkMs = 0;
15
+ let totalWaitingMs = 0;
16
+ let totalCost = 0;
17
+ let totalInput = 0;
18
+ let totalCacheRead = 0;
19
+ let totalCacheWrite = 0;
20
+ for (const project of projects) {
21
+ const summary = summarize(project.records, { all: false, day: options.today });
22
+ if (summary.tasks.count === 0)
23
+ continue;
24
+ const waitingMs = summary.orchestrator.waitingMs + summary.subagent.waitingMs;
25
+ rows.push({
26
+ name: project.name,
27
+ tasks: summary.tasks.count,
28
+ wallMs: summary.tasks.wallMs,
29
+ workMs: summary.tasks.workMs,
30
+ waitingMs,
31
+ cost: summary.tasks.cost,
32
+ cacheHit: cacheHitRatio(summary.tasks),
33
+ });
34
+ totalTasks += summary.tasks.count;
35
+ totalWallMs += summary.tasks.wallMs;
36
+ totalWorkMs += summary.tasks.workMs;
37
+ totalWaitingMs += waitingMs;
38
+ totalCost += summary.tasks.cost;
39
+ totalInput += summary.tasks.input;
40
+ totalCacheRead += summary.tasks.cacheRead;
41
+ totalCacheWrite += summary.tasks.cacheWrite;
42
+ }
43
+ rows.sort((a, b) => b.workMs - a.workMs);
44
+ const total = {
45
+ name: "total",
46
+ tasks: totalTasks,
47
+ wallMs: totalWallMs,
48
+ workMs: totalWorkMs,
49
+ waitingMs: totalWaitingMs,
50
+ cost: totalCost,
51
+ cacheHit: cacheHitRatio({ input: totalInput, cacheRead: totalCacheRead, cacheWrite: totalCacheWrite }),
52
+ };
53
+ return { rows, total };
54
+ }
55
+ /** Render `<minutes>m` or, once at least an hour, `<hours>h<minutes>m` (minutes zero-padded to two digits). */
56
+ export function formatMinutes(ms) {
57
+ const totalMinutes = Math.round(ms / 60000);
58
+ if (totalMinutes < 60)
59
+ return `${totalMinutes}m`;
60
+ const hours = Math.floor(totalMinutes / 60);
61
+ const minutes = totalMinutes % 60;
62
+ return `${hours}h${String(minutes).padStart(2, "0")}m`;
63
+ }
64
+ /** Render `$X.YZ`. */
65
+ function formatCost(cost) {
66
+ return `$${cost.toFixed(2)}`;
67
+ }
68
+ function formatRow(row) {
69
+ const cacheHitPart = row.cacheHit !== undefined ? ` cache hit ${Math.round(row.cacheHit * 100)}%` : "";
70
+ return `${row.name} tasks ${row.tasks} wall ${formatMinutes(row.wallMs)} work ${formatMinutes(row.workMs)} wait ${formatMinutes(row.waitingMs)} ${formatCost(row.cost)}${cacheHitPart}`;
71
+ }
72
+ /** Render one aligned line per row, plus a final total line. */
73
+ export function formatTodayLines(rows, total) {
74
+ return [...rows.map(formatRow), formatRow(total)];
75
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/ui/app.js ADDED
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { useApp, useInput } from "ink";
4
+ import { INITIAL_NAV_STATE, clearProjectFilter, focusMain, focusSidebar, moveSidebar, openProjectInTasks, screenForKey, switchScreen, } from "../domain/nav-model.js";
5
+ import { DashboardScreen } from "./dashboard-screen.js";
6
+ import { TasksScreen } from "./tasks-screen.js";
7
+ import { CatalogScreen } from "./catalog-screen.js";
8
+ import { SyncScreen } from "./sync-screen.js";
9
+ /**
10
+ * The app shell: owns navigation state (`domain/nav-model.ts`) and renders
11
+ * whichever screen is active, each already wrapped in the shared
12
+ * header/sidebar/footer frame (`ui/layout.tsx`). One `useInput` here owns
13
+ * every global key and the focus routing between the sidebar and the
14
+ * active screen's own main zone (see "Focus zones" in
15
+ * `odd/tasks/visual-system.md`): sidebar focused, `↑`/`↓` move the active
16
+ * screen and `enter`/`→`/`Tab` focus main; main focused, `←`/`Tab` return
17
+ * to the sidebar and `esc` does too, unless the active screen consumes it
18
+ * first (Tasks with a project filter set: the first `esc` clears it, the
19
+ * next returns). `1`-`4` and `q` stay global regardless of focus; `q`
20
+ * quits from anywhere (Ink's own `useApp().exit()`). Dashboard's `enter` on a
21
+ * project row (only reachable once main is focused) opens Tasks filtered
22
+ * to it, keeping focus on main; Tasks' own `esc` handling (gated the same
23
+ * way as every other screen key, by its `focused` prop) clears that
24
+ * filter — this hook only predicts whether that will happen, to decide
25
+ * whether it should also move focus.
26
+ */
27
+ export function App({ roots, version, loadToday, loadTasks, catalog, sync, dashboardActions }) {
28
+ const { exit } = useApp();
29
+ const [nav, setNav] = useState(INITIAL_NAV_STATE);
30
+ useInput((input, key) => {
31
+ if (input === "q") {
32
+ exit();
33
+ return;
34
+ }
35
+ const nextScreen = screenForKey(input);
36
+ if (nextScreen !== undefined) {
37
+ setNav((state) => switchScreen(state, nextScreen));
38
+ return;
39
+ }
40
+ setNav((state) => {
41
+ if (state.focus === "sidebar") {
42
+ if (key.downArrow)
43
+ return moveSidebar(state, 1);
44
+ if (key.upArrow)
45
+ return moveSidebar(state, -1);
46
+ if (key.return || key.rightArrow || key.tab)
47
+ return focusMain(state);
48
+ return state;
49
+ }
50
+ // Main zone focused.
51
+ if (key.leftArrow || key.tab)
52
+ return focusSidebar(state);
53
+ if (key.escape) {
54
+ // Tasks consumes the first `esc` itself (via its own `onClearFilter`,
55
+ // gated by its `focused` prop just like every other screen key) to
56
+ // clear its project filter; only move focus once there is none left.
57
+ const consumedByTasks = state.screen === "tasks" && state.projectFilter !== undefined;
58
+ return consumedByTasks ? state : focusSidebar(state);
59
+ }
60
+ return state;
61
+ });
62
+ });
63
+ const focused = nav.focus === "main";
64
+ 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 })] }));
65
+ }
@@ -0,0 +1,100 @@
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
+ const CLIENT_COLUMNS = [{ key: "name", header: "client", width: 20 }];
10
+ function clientCell(client) {
11
+ return `${client.name} (${client.code})`;
12
+ }
13
+ const PROJECT_COLUMNS = [
14
+ { key: "name", header: "project", width: 18 },
15
+ { key: "open", header: "open", width: 5, align: "right" },
16
+ { key: "doing", header: "doing", width: 5, align: "right" },
17
+ ];
18
+ function projectCell(project, key) {
19
+ switch (key) {
20
+ case "name":
21
+ return project.name;
22
+ case "open":
23
+ return String(project.openCount);
24
+ case "doing":
25
+ return String(project.doingCount);
26
+ default:
27
+ return "";
28
+ }
29
+ }
30
+ const KEY_HINTS = [
31
+ { key: "↑↓", label: "select" },
32
+ { key: "r", label: "refresh" },
33
+ { key: "1-4", label: "screens" },
34
+ { key: "q", label: "quit" },
35
+ ];
36
+ /** Below this main-content width, the Projects panel stacks under Clients instead of beside it. */
37
+ const DETAIL_BREAKPOINT = 70;
38
+ /** Rows a panel's own chrome (border + table header) takes outside its data rows. */
39
+ const TABLE_CHROME_ROWS = 3;
40
+ /**
41
+ * The Catalog screen: a `[ Clients ]` list and, beside it, the selected
42
+ * client's `[ Projects ]` with open/doing hub task counts — via
43
+ * `adapters/hub.ts`'s `CachedCatalog`, never reimplemented here. `r`
44
+ * refreshes against the hub; `↑↓` move the client selection. Without hub
45
+ * credentials, or with no cache yet, shows a one-line note instead.
46
+ */
47
+ export function CatalogScreen({ load, refresh, roots, version, columns, rows, focused = true }) {
48
+ const [model, setModel] = useState(load);
49
+ const [refreshing, setRefreshing] = useState(false);
50
+ const [selected, setSelected] = useState(0);
51
+ const maxRowsRef = useRef(0);
52
+ const clients = model.status === "ready" ? model.clients : [];
53
+ useInput((input, key) => {
54
+ const lastIndex = Math.max(clients.length - 1, 0);
55
+ if (input === "r" && !refreshing) {
56
+ setRefreshing(true);
57
+ void refresh().then((next) => {
58
+ setModel(next);
59
+ setRefreshing(false);
60
+ });
61
+ }
62
+ else if (key.downArrow) {
63
+ setSelected((index) => Math.min(index + 1, lastIndex));
64
+ }
65
+ else if (key.upArrow) {
66
+ setSelected((index) => Math.max(index - 1, 0));
67
+ }
68
+ else if (key.pageDown) {
69
+ setSelected((index) => Math.min(index + Math.max(maxRowsRef.current, 1), lastIndex));
70
+ }
71
+ else if (key.pageUp) {
72
+ setSelected((index) => Math.max(index - Math.max(maxRowsRef.current, 1), 0));
73
+ }
74
+ else if (key.home) {
75
+ setSelected(0);
76
+ }
77
+ else if (key.end) {
78
+ setSelected(lastIndex);
79
+ }
80
+ }, { isActive: focused });
81
+ if (model.status === "unavailable") {
82
+ return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, sidebarItems: SCREENS, activeId: "catalog", sidebarStats: [`roots ${roots.length}`], keyHints: hintsFor("catalog", focused ? "main" : "sidebar", KEY_HINTS), focus: focused ? "main" : "sidebar", children: () => _jsx(Text, { dimColor: true, children: model.reason ?? "no catalog cached yet" }) }));
83
+ }
84
+ const selectedClient = clients[Math.min(selected, Math.max(clients.length - 1, 0))];
85
+ const ageText = `fetched ${formatMinutes(model.ageMs)} ago${model.stale ? " (stale)" : ""}`;
86
+ return (_jsx(Layout, { columns: columns, rows: rows, headerLeft: `>_ kankaku ${version}`, headerRight: refreshing ? "refreshing…" : undefined, sidebarItems: SCREENS, activeId: "catalog", sidebarStats: [`clients ${clients.length}`], keyHints: hintsFor("catalog", focused ? "main" : "sidebar", KEY_HINTS), focus: focused ? "main" : "sidebar", children: ({ mainWidth, mainHeight }) => {
87
+ const wide = mainWidth >= DETAIL_BREAKPOINT;
88
+ const leftWidth = wide ? Math.floor(mainWidth * 0.4) : mainWidth;
89
+ const rightWidth = wide ? Math.max(mainWidth - leftWidth - 1, 1) : mainWidth;
90
+ // Stacked mode splits the height between Clients and Projects, mirroring the width split used in wide mode.
91
+ const clientsHeight = wide ? mainHeight : Math.max(Math.floor(mainHeight * 0.4), TABLE_CHROME_ROWS + 1);
92
+ const projectsHeight = wide ? mainHeight : Math.max(mainHeight - clientsHeight, TABLE_CHROME_ROWS + 1);
93
+ const clientsMaxRows = Math.max(clientsHeight - TABLE_CHROME_ROWS, 1);
94
+ const projectsMaxRows = Math.max(projectsHeight - TABLE_CHROME_ROWS, 1);
95
+ maxRowsRef.current = clientsMaxRows;
96
+ const clientsPanel = (_jsx(Panel, { title: "Clients", headerRight: ageText, width: leftWidth, height: clientsHeight, active: focused, children: _jsx(Table, { columns: CLIENT_COLUMNS, rows: clients, rowKey: (client) => client.id, cell: (client) => clientCell(client), selectedIndex: selected, emptyText: "no clients", maxRows: clientsMaxRows }) }));
97
+ const projectsPanel = (_jsx(Panel, { title: "Projects", width: rightWidth, height: projectsHeight, children: _jsx(Table, { columns: PROJECT_COLUMNS, rows: selectedClient?.projects ?? [], rowKey: (project) => project.id, cell: projectCell, emptyText: "no projects", maxRows: projectsMaxRows }) }));
98
+ return wide ? (_jsxs(Box, { flexDirection: "row", children: [clientsPanel, _jsx(Box, { width: 1 }), projectsPanel] })) : (_jsxs(Box, { flexDirection: "column", children: [clientsPanel, projectsPanel] }));
99
+ } }));
100
+ }
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { useTheme } from "../theme.js";
4
+ /** How many of `width` characters are filled for `value / max` (clamped to `[0, width]`; `max <= 0` is always `0`, never a division by zero). */
5
+ function filledWidth(value, max, width) {
6
+ const ratio = max > 0 ? value / max : 0;
7
+ return Math.max(0, Math.min(width, Math.round(ratio * width)));
8
+ }
9
+ /** The plain-text form of a bar (`█` for the filled portion, `░` for the rest), reusable anywhere a coloured `<Bar>` element does not fit, e.g. a `Table` cell. */
10
+ export function renderBar(value, max, width) {
11
+ const filled = filledWidth(value, max, width);
12
+ return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
13
+ }
14
+ /** A `width`-character text bar: `█` for the filled portion (`value / max`, clamped to `[0, width]`), `░` for the rest. `max <= 0` renders fully empty rather than dividing by zero. */
15
+ export function Bar({ value, max, width, color }) {
16
+ const theme = useTheme();
17
+ const filled = filledWidth(value, max, width);
18
+ const empty = width - filled;
19
+ return (_jsxs(Text, { children: [_jsx(Text, { color: color ?? theme.accent, children: "█".repeat(filled) }), _jsx(Text, { color: theme.muted, children: "░".repeat(empty) })] }));
20
+ }
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useTheme } from "../theme.js";
4
+ /** The top header line: `left` and `right` at opposite ends of `width`, via flex `space-between` rather than manual padding. */
5
+ export function HeaderBar({ left, right, width }) {
6
+ const theme = useTheme();
7
+ return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: theme.text, children: left }), right !== undefined && _jsx(Text, { color: theme.muted, children: right })] }));
8
+ }
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { useTheme } from "../theme.js";
4
+ /** The footer key-hints line: `key` in the accent colour, `label` in muted, each pair separated by three spaces. */
5
+ export function KeyHints({ hints }) {
6
+ const theme = useTheme();
7
+ return (_jsx(Text, { children: hints.map((hint, index) => (_jsxs(Text, { children: [index > 0 ? " " : "", _jsx(Text, { color: theme.accent, children: hint.key }), _jsx(Text, { color: theme.muted, children: ` ${hint.label}` })] }, `${hint.key}-${hint.label}`))) }));
8
+ }
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useTheme } from "../theme.js";
4
+ /**
5
+ * Build the top border as `╭─[ Title ]───…───headerRight╮`. Ink's `Box`
6
+ * has no title-in-border support, so this line is rendered as plain text
7
+ * above a `Box` whose own top border is turned off (`borderTop={false}`).
8
+ * Never shrinks below what `title`/`headerRight` need, even past `width`.
9
+ */
10
+ function buildTopBorder(title, headerRight, width) {
11
+ const left = `─[ ${title} ]`;
12
+ const right = headerRight ? `${headerRight} ` : "";
13
+ const minWidth = left.length + right.length + 2;
14
+ const total = Math.max(width, minWidth);
15
+ const fill = total - left.length - right.length - 2;
16
+ return `╭${left}${"─".repeat(Math.max(fill, 0))}${right}╮`;
17
+ }
18
+ /**
19
+ * A rounded, titled panel: the title sits inside the top border, an
20
+ * optional `headerRight` note sits right-aligned on the same line, and
21
+ * `children` render inside a rounded frame below it. `active` swaps the
22
+ * border colour to `theme.borderActive` (e.g. the focused panel in a
23
+ * multi-panel screen).
24
+ */
25
+ export function Panel({ title, headerRight, active = false, width, height, flexGrow, children }) {
26
+ const theme = useTheme();
27
+ const borderColor = active ? theme.borderActive : theme.border;
28
+ const topBorder = buildTopBorder(title, headerRight, width);
29
+ /** The top border is its own line above the bordered box, so the box's own height (which includes its bottom border) is one row less than the panel's total. */
30
+ const bodyHeight = height !== undefined ? Math.max(height - 1, 1) : undefined;
31
+ return (_jsxs(Box, { flexDirection: "column", width: Math.max(width, topBorder.length), height: height, flexGrow: flexGrow, children: [_jsx(Text, { color: borderColor, children: topBorder }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderTop: false, borderColor: borderColor, width: width, height: bodyHeight, flexGrow: flexGrow !== undefined ? 1 : undefined, overflow: "hidden", children: children })] }));
32
+ }
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useTheme } from "../theme.js";
4
+ /**
5
+ * The left-hand navigation column: one line per {@link SidebarItem} (the
6
+ * active one prefixed `› `, others ` ` — a visible marker rather than
7
+ * colour alone), a divider, then `stats`. Ink's `Box` border draws around
8
+ * the whole column, so the divider is a plain `─` fill line rather than a
9
+ * true `├─…─┤` junction.
10
+ *
11
+ * `focused` makes the focused zone unmistakable (see the "the menu column
12
+ * does not show as marked" fix in the project's task notes): the column's
13
+ * own border switches to `theme.borderActive`, and the active item gets a
14
+ * full-row `theme.selectionBg` highlight — its label is padded to the
15
+ * column's inner width first, since a `Text` background colour only
16
+ * covers its own rendered characters, not the blank cells Ink's fixed-width
17
+ * border box fills in around a shorter string. Unfocused, the active item
18
+ * keeps its plain `›` marker in `theme.muted` with no background — a
19
+ * structural marker, not colour alone, stays the primary cue either way.
20
+ */
21
+ export function Sidebar({ items, activeId, stats, width, focused = true }) {
22
+ const theme = useTheme();
23
+ const inner = Math.max(width - 2, 1);
24
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: focused ? theme.borderActive : theme.border, width: width, children: [items.map((item) => {
25
+ const active = item.id === activeId;
26
+ const marker = active ? "› " : " ";
27
+ const label = active && focused ? `${marker}${item.label}`.padEnd(inner) : `${marker}${item.label}`;
28
+ return (_jsx(Text, { color: active ? (focused ? theme.accent : theme.muted) : theme.text, backgroundColor: active && focused ? theme.selectionBg : undefined, bold: active && focused, children: label }, item.id));
29
+ }), _jsx(Text, { color: theme.border, children: "─".repeat(inner) }), stats.map((line, index) => (_jsx(Text, { color: theme.muted, children: line }, index)))] }));
30
+ }
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { useTheme } from "../theme.js";
4
+ const LEVELS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
5
+ /** One block character per value (`▁`–`█`), scaled to the series' own max. An empty series renders `·`; an all-zero series renders every bar at the lowest level rather than collapsing to nothing. */
6
+ export function Sparkline({ values, color }) {
7
+ const theme = useTheme();
8
+ if (values.length === 0)
9
+ return _jsx(Text, { color: theme.muted, children: "\u00B7" });
10
+ const max = Math.max(...values, 0);
11
+ const text = values
12
+ .map((value) => {
13
+ const ratio = max > 0 ? value / max : 0;
14
+ const level = Math.max(0, Math.min(LEVELS.length - 1, Math.round(ratio * (LEVELS.length - 1))));
15
+ return LEVELS[level];
16
+ })
17
+ .join("");
18
+ return _jsx(Text, { color: color ?? theme.accent, children: text });
19
+ }
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { useTheme } from "../theme.js";
5
+ import { windowRows } from "../../domain/list-window.js";
6
+ /**
7
+ * Resolves the visible slice of `rows` for a given `maxRows` body-line
8
+ * budget: a data window sized so that, once indicator lines are added for
9
+ * whichever side is hidden, the total body still fits `maxRows`. Converges
10
+ * in at most two passes since adding an indicator can only ever remove
11
+ * hidden rows on that same side.
12
+ */
13
+ function resolveWindow(total, selected, maxRows, previousStart) {
14
+ let budget = maxRows;
15
+ let window = windowRows(total, selected, budget, previousStart);
16
+ for (let pass = 0; pass < 2; pass += 1) {
17
+ const reserve = (window.hiddenAbove > 0 ? 1 : 0) + (window.hiddenBelow > 0 ? 1 : 0);
18
+ const nextBudget = Math.max(maxRows - reserve, 1);
19
+ if (nextBudget === budget)
20
+ break;
21
+ budget = nextBudget;
22
+ window = windowRows(total, selected, budget, previousStart);
23
+ }
24
+ return window;
25
+ }
26
+ /** Fit `text` into `width` visible columns: truncate with a trailing `…` when too long, then pad to `width` (left- or right-aligned). */
27
+ function fitCell(text, width, align) {
28
+ const value = text.length > width ? (width > 1 ? `${text.slice(0, width - 1)}…` : text.slice(0, width)) : text;
29
+ const padding = " ".repeat(Math.max(width - value.length, 0));
30
+ return align === "right" ? `${padding}${value}` : `${value}${padding}`;
31
+ }
32
+ function rowText(columns, values) {
33
+ return columns.map((column) => fitCell(values(column), column.width, column.align ?? "left")).join(" ");
34
+ }
35
+ /**
36
+ * An aligned table: a muted header row, one line per data row prefixed with
37
+ * `› ` on the selected row (a visible marker, never colour alone — see
38
+ * AGENTS.md's ink-testing-library note) and ` ` otherwise. A cell wider
39
+ * than its column truncates with `…`; `emptyText` renders instead of the
40
+ * body when `rows` is empty.
41
+ */
42
+ export function Table({ columns, rows, rowKey, cell, selectedIndex, emptyText = "no rows", maxRows }) {
43
+ const theme = useTheme();
44
+ const startRef = useRef(0);
45
+ const windowed = maxRows !== undefined && rows.length > maxRows;
46
+ const window = windowed ? resolveWindow(rows.length, selectedIndex ?? 0, maxRows, startRef.current) : undefined;
47
+ if (window !== undefined)
48
+ startRef.current = window.start;
49
+ const visibleRows = window !== undefined ? rows.slice(window.start, window.end) : rows;
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
+ const actualIndex = (window?.start ?? 0) + index;
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)));
54
+ }), window !== undefined && window.hiddenBelow > 0 && (_jsx(Text, { color: theme.muted, children: ` ↓ ${window.hiddenBelow} more` }, "__indicator-below__"))] }))] }));
55
+ }