@kud/jira-ink 0.2.1 → 0.3.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
@@ -1,9 +1,9 @@
1
1
  # @kud/jira-ink
2
2
 
3
3
  Controlled [Ink](https://github.com/vadimdemedes/ink) components for viewing
4
- Jira issues in the terminal — the issue detail screen, extracted from
5
- [`@kud/jira-cli`](https://github.com/kud/jira-cli)'s TUI so it can be shared
6
- with ambre's cockpit.
4
+ Jira issues in the terminal — the board and the issue detail screen, extracted
5
+ from [`@kud/jira-cli`](https://github.com/kud/jira-cli)'s TUI so they can be
6
+ shared with ambre's cockpit.
7
7
 
8
8
  Every component is **presentation-only**: props in, no data fetching, no
9
9
  app-level input. The consuming surface owns selection, navigation and loading,
@@ -71,6 +71,49 @@ const issue = await issueDetailOf(client, baseUrl, "SHOP-1234")
71
71
  | `issueDetailOf` | Fetches one issue through a `@kud/jira` client into the view-ready shape |
72
72
  | `transitionsOf` | The issue's available status transitions, `{ id, name, to }` |
73
73
  | `IssueDetail` | The shape the view reads: strings throughout, ADF already Markdown |
74
+
75
+ ### The board
76
+
77
+ A tabbed list of issues: rows grouped under their epic, a type pill, a priority glyph and an age on each, and a `/` search that takes plain words or JQL. The host owns the frame, the fetch and the quit key; the board owns only the layers it pushes itself (its search box and its legend).
78
+
79
+ ```tsx
80
+ import { boardOf, IssueBoard } from "@kud/jira-ink"
81
+
82
+ const model = await boardOf(client, "assignee = currentUser() ORDER BY updated DESC", {
83
+ board: config.defaultBoard, // optional — its columns become the tabs
84
+ tabs: config.tabs, // optional — hand-written tabs win over the board
85
+ })
86
+
87
+ <IssueBoard
88
+ model={model}
89
+ viewer="you"
90
+ loadedAt={Date.now()}
91
+ scope={{ kind: "mine" }}
92
+ showingAll={false}
93
+ searchError={null}
94
+ width={100}
95
+ height={30}
96
+ onOpen={openIssue}
97
+ onToggleAll={toggleAll}
98
+ onRefresh={refresh}
99
+ onSearch={runSearch}
100
+ onClearSearch={clearSearch}
101
+ frame={({ facts, hints, body }) => <MyFrame facts={facts} hints={hints}>{body}</MyFrame>}
102
+ />
103
+ ```
104
+
105
+ **Tabs come from one of three places, in order of precedence.** Hand-written `tabs` in the config file (statuses by id or name); the board's own column configuration (`getBoardConfiguration`, statuses matched **by id**, never by name); or Jira's status categories — To do, In progress, Done — which every status on every instance carries, so the last needs no configuration and can never name one workflow's vocabulary. Statuses no configured tab claims go to a derived **Off board** tab, drawn only when it holds something; a board with no columns falls through to categories. The board supplies the *vocabulary*, never the population: the fetch stays one query, bucketed client-side.
106
+
107
+ | Export | What it is |
108
+ | --- | --- |
109
+ | `IssueBoard` | The tabbed board over a `BoardModel`; a `frame` slot hands the host its title facts, hints and body |
110
+ | `boardOf` | Runs one JQL through a `@kud/jira` client and derives the tabs |
111
+ | `toBoardRow` | One `JiraIssue` → `BoardRow`, the container question answered via `isContainerType` |
112
+ | `tabsFromConfig` · `tabsFromBoard` · `tabsFromCategories` | The three tab sources, each a `BoardTabs` with its `source` named |
113
+ | `tabOf` · `countsFor` · `visibleTabs` | Which tab a row belongs to, per-tab counts, which tabs to draw |
114
+ | `blocksFor` | One tab's rows as drawn: container rows head their group, absent parents get a fence, orphans last, a gap between groups |
115
+ | `priorityGlyph` · `relativeAge` · `pillVariantFor` | Row glyphs |
116
+ | `mockBoard` · `MOCK_BOARD_ROWS` | An invented board for screenshots and tests |
74
117
  ## Development
75
118
 
76
119
  ```sh
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
  import { MarkdownTheme } from '@kud/ink-markdown';
4
4
  import { Hint } from '@kud/ink-ui';
5
- import { LocatedAttachment, JiraClient } from '@kud/jira';
5
+ import { LocatedAttachment, JiraClient, BoardTab as BoardTab$1, JiraBoardConfiguration, JiraIssue, SearchMode } from '@kud/jira';
6
6
 
7
7
  /**
8
8
  * One issue, as the detail screen reads it: every field already a string, ADF
@@ -99,4 +99,172 @@ type IssueBodyProps = {
99
99
  */
100
100
  declare const IssueBody: ({ client, baseUrl, issueKey, onExit, width, height, frame, theme, }: IssueBodyProps) => string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | react.JSX.Element | null | undefined;
101
101
 
102
- export { IssueBody, type IssueBodyProps, type IssueDetail, IssueDetailView, type IssueDetailViewProps, type Transition, issueDetailOf, transitionsOf };
102
+ /** Jira's own three, plus `unknown` for a status that arrives without one. */
103
+ type StatusCategory = "new" | "indeterminate" | "done" | "unknown";
104
+ /**
105
+ * One issue, as the board reads it: every field already a string, the
106
+ * container question already answered. The view never touches a raw
107
+ * `JiraIssue`, so a host that fakes the fetch gets a board it can predict.
108
+ * The field names deliberately match the cockpit's own row vocabulary
109
+ * (`key`, `container`, `parent`) without importing it.
110
+ */
111
+ type BoardRow = {
112
+ key: string;
113
+ summary: string;
114
+ status: string;
115
+ statusId: string;
116
+ category: StatusCategory;
117
+ type: string;
118
+ container: boolean;
119
+ priority: string | null;
120
+ parent?: {
121
+ key: string;
122
+ summary: string;
123
+ };
124
+ assignee: string;
125
+ /** ISO timestamp, kept whole so the view can draw a relative age. */
126
+ updated: string;
127
+ };
128
+ declare const toBoardRow: (issue: JiraIssue) => BoardRow;
129
+ /**
130
+ * Where a board's tabs came from, in order of precedence: the user's own
131
+ * `tabs` in the config file, then the board's column configuration, then
132
+ * Jira's status categories — which every status on every instance carries,
133
+ * so the last one needs no configuration and can never name one workflow's
134
+ * vocabulary.
135
+ */
136
+ type TabSource = "config" | "board" | "categories";
137
+ type BoardTab = {
138
+ value: string;
139
+ label: string;
140
+ /** Status ids (or, for a config tab, names) this tab claims. Absent on a category tab. */
141
+ statuses?: Set<string>;
142
+ category?: StatusCategory;
143
+ /** The derived tab holding statuses no other tab claims. */
144
+ offBoard?: boolean;
145
+ };
146
+ type BoardTabs = {
147
+ source: TabSource;
148
+ tabs: BoardTab[];
149
+ };
150
+ declare const OFF_BOARD = "__off_board__";
151
+ declare const tabsFromConfig: (configured: BoardTab$1[]) => BoardTabs;
152
+ /**
153
+ * Columns are matched to statuses by ID, never by name — name-matching is
154
+ * how a transition table once silently stopped matching anything.
155
+ */
156
+ declare const tabsFromBoard: (configuration: JiraBoardConfiguration) => BoardTabs | null;
157
+ declare const tabsFromCategories: () => BoardTabs;
158
+ /** The tab a row belongs to. Nothing ever falls out: unclaimed rows go Off board, or In progress on a category board. */
159
+ declare const tabOf: (row: BoardRow, { tabs }: BoardTabs) => string;
160
+ declare const countsFor: (rows: BoardRow[], tabs: BoardTabs) => Record<string, number>;
161
+ /** Tabs worth drawing: every configured one, plus Off board only when it holds something. */
162
+ declare const visibleTabs: (tabs: BoardTabs, counts: Record<string, number>) => BoardTab[];
163
+ /**
164
+ * What one tab draws, in order. A container that is itself in the tab HEADS
165
+ * its group as a selectable row, its children hanging beneath it; a parent
166
+ * that is not in the tab gets a dim fence instead, because there is no row
167
+ * to select. Container-headed groups come first, then fenced ones, then rows
168
+ * with no parent — and each group after the first is preceded by a blank
169
+ * line. A tab where nothing has a parent and nothing is a container degrades
170
+ * to a plain list, never to a lone fence.
171
+ */
172
+ type Block = {
173
+ kind: "gap";
174
+ } | {
175
+ kind: "fence";
176
+ key: string | null;
177
+ summary: string;
178
+ } | {
179
+ kind: "issue";
180
+ row: BoardRow;
181
+ depth: 0 | 1;
182
+ };
183
+ declare const blocksFor: (rows: BoardRow[]) => Block[];
184
+ /** Index into `blocks` of the n-th issue, so a cursor over issues maps to a line. */
185
+ declare const blockIndexOfIssue: (blocks: Block[], issueIndex: number) => number;
186
+ /**
187
+ * Priority names are per-instance, so this matches the default scheme, the
188
+ * common renames and the P0–P4 ladder rather than an exact list. Medium — or
189
+ * anything unrecognised — draws nothing: the glyph column marks the two ends
190
+ * only.
191
+ */
192
+ declare const priorityGlyph: (name: string | null) => "\u25B2" | "\u25BC" | " ";
193
+ declare const relativeAge: (iso: string, now?: number) => string;
194
+ declare const pillVariantFor: (row: Pick<BoardRow, "type" | "container">) => "group" | "info" | "error" | "muted";
195
+ type BoardModel = {
196
+ rows: BoardRow[];
197
+ tabs: BoardTabs;
198
+ };
199
+ type BoardOptions = {
200
+ /** Hand-written tabs from the config file — wins over everything. */
201
+ tabs?: BoardTab$1[];
202
+ /** A board whose column configuration supplies the tabs. */
203
+ board?: number;
204
+ limit?: number;
205
+ };
206
+ /**
207
+ * One assignee-scoped query, bucketed client-side. The board supplies the
208
+ * VOCABULARY, never the population — `getBoardIssues` would drop your
209
+ * tickets on every other board. A board with no columns, or none given,
210
+ * falls through to categories.
211
+ */
212
+ declare const boardOf: (client: JiraClient, jql: string, { tabs, board, limit }?: BoardOptions) => Promise<BoardModel>;
213
+
214
+ /** What the board is showing, named in the title row. */
215
+ type BoardScope = {
216
+ kind: "mine";
217
+ } | {
218
+ kind: "search";
219
+ query: string;
220
+ mode: "jql" | "text";
221
+ };
222
+ type IssueBoardProps = {
223
+ model: BoardModel;
224
+ /** Who the `mine` scope belongs to, for the title row. */
225
+ viewer: string;
226
+ loadedAt: number;
227
+ scope: BoardScope;
228
+ /** Whether closed work beyond the recent window has been loaded. */
229
+ showingAll: boolean;
230
+ /** Jira's own words for a query it rejected; drawn under the search box, query kept. */
231
+ searchError: string | null;
232
+ width: number;
233
+ height: number;
234
+ onOpen: (key: string) => void;
235
+ onToggleAll: () => void;
236
+ onRefresh: () => void;
237
+ onSearch: (query: string, mode: SearchMode) => void;
238
+ onClearSearch: () => void;
239
+ /**
240
+ * The host draws the chrome — border, title row, hints at the foot — and
241
+ * places the parts. Without it the board draws a plain column, which is
242
+ * what a test or an embedded pane wants.
243
+ */
244
+ frame?: (parts: {
245
+ facts: string;
246
+ hints: Hint[];
247
+ body: ReactNode;
248
+ }) => ReactNode;
249
+ };
250
+ /**
251
+ * Rows are windowed by hand rather than handed to `Table`, which renders every
252
+ * row and has no notion of a cursor. A backlog of two hundred issues would
253
+ * otherwise scroll the selected row off the top of the terminal.
254
+ *
255
+ * The board binds no `q` and no back key of its own — those belong to the
256
+ * host — only the keys that open and close the layers it pushes itself: the
257
+ * search input and the legend.
258
+ */
259
+ declare const IssueBoard: ({ model, viewer, loadedAt, scope, showingAll, searchError, width, height, onOpen, onToggleAll, onRefresh, onSearch, onClearSearch, frame, }: IssueBoardProps) => string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | react.JSX.Element | null | undefined;
260
+
261
+ /**
262
+ * An invented board for screenshots and tests — every key, name and host is
263
+ * made up. It carries what the contract must keep exercising: a container
264
+ * that is itself in a tab with a child under it, a parent that is not, an
265
+ * orphan, all three categories, and both ends of priority.
266
+ */
267
+ declare const MOCK_BOARD_ROWS: BoardRow[];
268
+ declare const mockBoard: () => BoardModel;
269
+
270
+ export { type Block, type BoardModel, type BoardOptions, type BoardRow, type BoardScope, type BoardTab, type BoardTabs, IssueBoard, type IssueBoardProps, IssueBody, type IssueBodyProps, type IssueDetail, IssueDetailView, type IssueDetailViewProps, MOCK_BOARD_ROWS, OFF_BOARD, type StatusCategory, type TabSource, type Transition, blockIndexOfIssue, blocksFor, boardOf, countsFor, issueDetailOf, mockBoard, pillVariantFor, priorityGlyph, relativeAge, tabOf, tabsFromBoard, tabsFromCategories, tabsFromConfig, toBoardRow, transitionsOf, visibleTabs };
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { MarkdownViewport } from '@kud/ink-markdown';
2
- import { useTabs, KeyValue, Badge, Tabs, FooterHints, StatusMessage, LoadingScreen } from '@kud/ink-ui';
2
+ import { useTabs, KeyValue, Badge, Tabs, FooterHints, StatusMessage, useListCursor, pillWidth, colors, TextInput, SelectableRow, Pill, LoadingScreen } from '@kud/ink-ui';
3
3
  import { useInput, Box, Text, useStdout } from 'ink';
4
- import { useState, useEffect } from 'react';
5
- import { jsxs, jsx } from 'react/jsx-runtime';
4
+ import { useState, useEffect, useMemo } from 'react';
5
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
6
  import { exec } from 'child_process';
7
- import { locateAttachments, adfToMarkdown } from '@kud/jira';
7
+ import { locateAttachments, adfToMarkdown, isContainerType, looksLikeJql } from '@kud/jira';
8
8
 
9
9
  // src/components/issue-detail-view.tsx
10
10
  var truncate = (s, max) => s.length <= max ? s : `${s.slice(0, Math.max(0, max - 1))}\u2026`;
@@ -223,5 +223,481 @@ var IssueBody = ({
223
223
  }
224
224
  );
225
225
  };
226
+ var CATEGORIES = ["new", "indeterminate", "done"];
227
+ var categoryOf = (key) => CATEGORIES.find((c) => c === key) ?? "unknown";
228
+ var toBoardRow = (issue) => ({
229
+ key: issue.key,
230
+ summary: issue.fields.summary ?? "",
231
+ status: issue.fields.status?.name ?? "\u2014",
232
+ statusId: issue.fields.status?.id ?? "",
233
+ category: categoryOf(issue.fields.status?.statusCategory?.key),
234
+ type: issue.fields.issuetype?.name ?? "",
235
+ container: isContainerType(issue.fields.issuetype),
236
+ priority: issue.fields.priority?.name ?? null,
237
+ ...issue.fields.parent ? {
238
+ parent: {
239
+ key: issue.fields.parent.key,
240
+ summary: issue.fields.parent.fields?.summary ?? ""
241
+ }
242
+ } : {},
243
+ assignee: issue.fields.assignee?.displayName ?? "unassigned",
244
+ updated: issue.fields.updated ?? ""
245
+ });
246
+ var OFF_BOARD = "__off_board__";
247
+ var CATEGORY_TABS = [
248
+ { value: "todo", label: "To do", category: "new" },
249
+ { value: "doing", label: "In progress", category: "indeterminate" },
250
+ { value: "done", label: "Done", category: "done" }
251
+ ];
252
+ var withOffBoard = (tabs) => [
253
+ ...tabs,
254
+ { value: OFF_BOARD, label: "Off board", offBoard: true }
255
+ ];
256
+ var tabsFromConfig = (configured) => ({
257
+ source: "config",
258
+ tabs: withOffBoard(
259
+ configured.map((t, i) => ({
260
+ value: `config:${i}`,
261
+ label: t.label,
262
+ statuses: new Set(t.statuses.map((s) => s.toLowerCase()))
263
+ }))
264
+ )
265
+ });
266
+ var tabsFromBoard = (configuration) => {
267
+ const columns = configuration.columnConfig?.columns ?? [];
268
+ if (columns.length === 0) return null;
269
+ return {
270
+ source: "board",
271
+ tabs: withOffBoard(
272
+ columns.map((c, i) => ({
273
+ value: `column:${i}`,
274
+ label: c.name,
275
+ statuses: new Set(c.statuses.map((s) => s.id))
276
+ }))
277
+ )
278
+ };
279
+ };
280
+ var tabsFromCategories = () => ({
281
+ source: "categories",
282
+ tabs: CATEGORY_TABS
283
+ });
284
+ var tabOf = (row, { tabs }) => {
285
+ const claimed = tabs.find(
286
+ (t) => t.statuses?.has(row.statusId) || t.statuses?.has(row.status.toLowerCase())
287
+ );
288
+ if (claimed) return claimed.value;
289
+ const byCategory = tabs.find((t) => t.category && t.category === row.category);
290
+ if (byCategory) return byCategory.value;
291
+ return tabs.find((t) => t.offBoard)?.value ?? "doing";
292
+ };
293
+ var countsFor = (rows, tabs) => {
294
+ const counts = Object.fromEntries(
295
+ tabs.tabs.map((t) => [t.value, 0])
296
+ );
297
+ for (const row of rows)
298
+ counts[tabOf(row, tabs)] = (counts[tabOf(row, tabs)] ?? 0) + 1;
299
+ return counts;
300
+ };
301
+ var visibleTabs = (tabs, counts) => tabs.tabs.filter((t) => !t.offBoard || (counts[t.value] ?? 0) > 0);
302
+ var blocksFor = (rows) => {
303
+ const hasStructure = rows.some((r) => r.parent || r.container);
304
+ if (!hasStructure)
305
+ return rows.map((row) => ({ kind: "issue", row, depth: 0 }));
306
+ const heads = new Map(rows.filter((r) => r.container).map((r) => [r.key, r]));
307
+ const children = /* @__PURE__ */ new Map();
308
+ const orphans = [];
309
+ for (const row of rows) {
310
+ if (row.container) continue;
311
+ const key = row.parent?.key;
312
+ if (!key) {
313
+ orphans.push(row);
314
+ continue;
315
+ }
316
+ children.set(key, [...children.get(key) ?? [], row]);
317
+ }
318
+ const groups = [];
319
+ for (const [key, head] of heads)
320
+ groups.push([
321
+ { kind: "issue", row: head, depth: 0 },
322
+ ...(children.get(key) ?? []).map((row) => ({
323
+ kind: "issue",
324
+ row,
325
+ depth: 1
326
+ }))
327
+ ]);
328
+ for (const [key, kids] of children) {
329
+ if (heads.has(key)) continue;
330
+ groups.push([
331
+ { kind: "fence", key, summary: kids[0].parent?.summary ?? "" },
332
+ ...kids.map((row) => ({ kind: "issue", row, depth: 1 }))
333
+ ]);
334
+ }
335
+ if (orphans.length)
336
+ groups.push([
337
+ ...groups.length ? [{ kind: "fence", key: null, summary: "No epic" }] : [],
338
+ ...orphans.map((row) => ({ kind: "issue", row, depth: 0 }))
339
+ ]);
340
+ return groups.flatMap(
341
+ (group, i) => i === 0 ? group : [{ kind: "gap" }, ...group]
342
+ );
343
+ };
344
+ var blockIndexOfIssue = (blocks, issueIndex) => {
345
+ let seen = -1;
346
+ for (let i = 0; i < blocks.length; i++) {
347
+ if (blocks[i].kind === "issue" && ++seen === issueIndex) return i;
348
+ }
349
+ return 0;
350
+ };
351
+ var priorityGlyph = (name) => {
352
+ const n = (name ?? "").trim().toLowerCase();
353
+ if (/highest|high|critical|blocker|urgent|^p[01]$/.test(n)) return "\u25B2";
354
+ if (/lowest|low|minor|trivial|^p[3-5]$/.test(n)) return "\u25BC";
355
+ return " ";
356
+ };
357
+ var UNITS = [
358
+ [60, "m"],
359
+ [60, "h"],
360
+ [24, "d"],
361
+ [7, "w"]
362
+ ];
363
+ var relativeAge = (iso, now = Date.now()) => {
364
+ const then = Date.parse(iso);
365
+ if (Number.isNaN(then)) return "";
366
+ let value = Math.max(0, Math.round((now - then) / 1e3));
367
+ let unit = "s";
368
+ for (const [size, next] of UNITS) {
369
+ if (value < size) break;
370
+ value = Math.round(value / size);
371
+ unit = next;
372
+ }
373
+ return `${value}${unit}`;
374
+ };
375
+ var pillVariantFor = (row) => {
376
+ if (row.container) return "group";
377
+ const t = row.type.toLowerCase();
378
+ if (t === "bug") return "error";
379
+ if (t === "story") return "info";
380
+ return "muted";
381
+ };
382
+ var boardOf = async (client, jql, { tabs, board, limit = 200 } = {}) => {
383
+ const [issues, derived] = await Promise.all([
384
+ client.searchIssues(jql, { limit }),
385
+ tabs?.length ? Promise.resolve(tabsFromConfig(tabs)) : board !== void 0 ? client.getBoardConfiguration(board).then(tabsFromBoard) : Promise.resolve(null)
386
+ ]);
387
+ return { rows: issues.map(toBoardRow), tabs: derived ?? tabsFromCategories() };
388
+ };
389
+ var CHROME = 6;
390
+ var LEGEND = [
391
+ ["\u25B2", "high priority"],
392
+ ["\u25BC", "low priority"],
393
+ ["\u2500\u2500 epic \u2500\u2500", "a parent not in this tab; its rows hang beneath"],
394
+ ["\u2514\u2500", "a row under the container above it"],
395
+ ["story \xB7 bug \xB7 task", "issue type"],
396
+ ["2d", "time since last update"]
397
+ ];
398
+ var windowFor = (focus, total, size) => {
399
+ const start = Math.max(
400
+ 0,
401
+ Math.min(focus - Math.floor(size / 2), total - size)
402
+ );
403
+ return { start, end: start + size };
404
+ };
405
+ var modeOf = (s) => s.mode === "auto" ? looksLikeJql(s.draft) ? "jql" : "text" : s.mode;
406
+ var rule = (label, width) => {
407
+ const head = `\u2500\u2500 ${label} `;
408
+ return head + "\u2500".repeat(Math.max(0, width - [...head].length));
409
+ };
410
+ var tabLegendLine = (model) => model.tabs.source === "categories" ? [
411
+ "To do \xB7 In progress \xB7 Done",
412
+ "status category \u2014 not any one board's column"
413
+ ] : model.tabs.source === "board" ? ["tabs", "this board's own columns, matched by status id"] : ["tabs", "your config file's tabs"];
414
+ var IssueBoard = ({
415
+ model,
416
+ viewer,
417
+ loadedAt,
418
+ scope,
419
+ showingAll,
420
+ searchError,
421
+ width,
422
+ height,
423
+ onOpen,
424
+ onToggleAll,
425
+ onRefresh,
426
+ onSearch,
427
+ onClearSearch,
428
+ frame
429
+ }) => {
430
+ const [legend, setLegend] = useState(false);
431
+ const [search, setSearch] = useState({
432
+ open: false,
433
+ draft: "",
434
+ mode: "auto"
435
+ });
436
+ const [now, setNow] = useState(Date.now);
437
+ useEffect(() => {
438
+ const tick = setInterval(() => setNow(Date.now()), 3e4);
439
+ return () => clearInterval(tick);
440
+ }, []);
441
+ const narrowed = useMemo(() => {
442
+ if (!search.open || modeOf(search) === "jql" || !search.draft.trim())
443
+ return model.rows;
444
+ const words = search.draft.trim().toLowerCase();
445
+ return model.rows.filter(
446
+ (r) => `${r.key} ${r.summary}`.toLowerCase().includes(words)
447
+ );
448
+ }, [model.rows, search]);
449
+ const counts = countsFor(narrowed, model.tabs);
450
+ const shown = visibleTabs(model.tabs, counts);
451
+ const tabItems = shown.map((t) => ({
452
+ value: t.value,
453
+ label: t.label,
454
+ count: counts[t.value] ?? 0
455
+ }));
456
+ const listFocused = !search.open && !legend;
457
+ const initial = shown.find((t) => t.category === "indeterminate")?.value ?? shown[0]?.value;
458
+ const { active, setActive } = useTabs(tabItems, {
459
+ initial,
460
+ isActive: listFocused
461
+ });
462
+ const tab = active ?? initial ?? "";
463
+ const blocks = useMemo(
464
+ () => blocksFor(narrowed.filter((r) => tabOf(r, model.tabs) === tab)),
465
+ [narrowed, model.tabs, tab]
466
+ );
467
+ const issues = blocks.filter(
468
+ (b) => b.kind === "issue"
469
+ );
470
+ const { cursor, setCursor } = useListCursor(issues.length, {
471
+ vimKeys: true,
472
+ isActive: listFocused
473
+ });
474
+ useEffect(() => setCursor(0), [tab, setCursor]);
475
+ useInput((input, key) => {
476
+ if (search.open) {
477
+ if (key.escape) setSearch((s) => ({ ...s, open: false }));
478
+ if (key.tab)
479
+ setSearch((s) => ({
480
+ ...s,
481
+ mode: modeOf(s) === "jql" ? "text" : "jql"
482
+ }));
483
+ return;
484
+ }
485
+ if (legend) {
486
+ if (input === "?" || key.escape) setLegend(false);
487
+ return;
488
+ }
489
+ if (key.return && issues[cursor]) onOpen(issues[cursor].row.key);
490
+ if ((key.leftArrow || key.rightArrow) && tabItems.length) {
491
+ const at = tabItems.findIndex((t) => t.value === tab);
492
+ const next = (at + (key.leftArrow ? -1 : 1) + tabItems.length) % tabItems.length;
493
+ setActive(tabItems[next].value);
494
+ }
495
+ if (input === "/")
496
+ setSearch((s) => ({
497
+ ...s,
498
+ open: true,
499
+ draft: scope.kind === "search" ? scope.query : s.draft
500
+ }));
501
+ if (input === "?") setLegend(true);
502
+ if (input === "a") onToggleAll();
503
+ if (input === "r") onRefresh();
504
+ if (input === "x" && scope.kind === "search") onClearSearch();
505
+ });
506
+ const searching = search.open || scope.kind === "search";
507
+ const chrome = CHROME + (searching ? 1 : 0) + (searchError ? 1 : 0);
508
+ const size = Math.max(3, height - chrome);
509
+ const focus = blockIndexOfIssue(blocks, cursor);
510
+ const { start, end } = windowFor(focus, blocks.length, size);
511
+ const keyWidth = Math.max(8, ...narrowed.map((r) => r.key.length));
512
+ const typeWidth = Math.max(
513
+ 0,
514
+ ...narrowed.map((r) => pillWidth(r.type.toLowerCase()))
515
+ );
516
+ const scopeLabel = scope.kind === "mine" ? `@${viewer}` : scope.mode === "jql" ? "jql" : `\u201C${scope.query}\u201D`;
517
+ const countLabel = search.open && narrowed.length !== model.rows.length ? `${narrowed.length} of ${model.rows.length}` : `${model.rows.length} ${model.rows.length === 1 ? "item" : "items"}`;
518
+ const facts = `${countLabel} \xB7 ${scopeLabel} \xB7 updated ${relativeAge(new Date(loadedAt).toISOString(), now)} ago`;
519
+ const hints = search.open ? [
520
+ ["enter", "run"],
521
+ ["\u21E5", modeOf(search) === "jql" ? "as plain words" : "as jql"],
522
+ ["esc", "cancel"]
523
+ ] : legend ? [["?", "close"]] : [
524
+ ["\u2191\u2193", "move"],
525
+ ["\u2190\u2192", "tab"],
526
+ ["enter", "open"],
527
+ ["/", scope.kind === "search" ? "edit" : "search"],
528
+ ...scope.kind === "search" ? [["x", "clear"]] : [["a", showingAll ? "recent only" : "everything"]],
529
+ ["r", "refresh"],
530
+ ["?", "legend"],
531
+ ["q", "quit"]
532
+ ];
533
+ const emptyHint = () => {
534
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
535
+ if (total === 0) {
536
+ if (scope.kind === "search")
537
+ return "Nothing matches \u2014 / to edit, x to clear.";
538
+ return showingAll ? "Nothing is assigned to you." : "No open issues assigned to you \u2014 press a to include everything.";
539
+ }
540
+ const elsewhere = shown.filter((t) => t.value !== tab && (counts[t.value] ?? 0) > 0).map((t) => `${t.label} (${counts[t.value]})`).join(", ");
541
+ const current = shown.find((t) => t.value === tab);
542
+ const closed = current?.category === "done" && !showingAll ? " Only the last 14 days of closed work is loaded \u2014 press a for everything." : "";
543
+ return `Nothing here \u2014 \u2190\u2192 to ${elsewhere}.${closed}`;
544
+ };
545
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
546
+ search.open ? /* @__PURE__ */ jsxs(Box, { paddingRight: 1, children: [
547
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.accent, children: " / " }) }),
548
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1, children: /* @__PURE__ */ jsx(
549
+ TextInput,
550
+ {
551
+ defaultValue: search.draft,
552
+ placeholder: "words, or JQL",
553
+ onChange: (draft) => setSearch((s) => ({ ...s, draft })),
554
+ onSubmit: (query) => {
555
+ setSearch((s) => ({ ...s, open: false, draft: query }));
556
+ if (query.trim()) onSearch(query, search.mode);
557
+ else onClearSearch();
558
+ }
559
+ }
560
+ ) }),
561
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: modeOf(search) === "jql" ? "JQL" : "plain" }) })
562
+ ] }) : scope.kind === "search" ? /* @__PURE__ */ jsxs(Box, { paddingRight: 1, children: [
563
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.accent, children: " / " }) }),
564
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1, children: /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: scope.query }) }),
565
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: scope.mode === "jql" ? "JQL" : "plain" }) })
566
+ ] }) : null,
567
+ searchError ? /* @__PURE__ */ jsxs(Text, { color: colors.error, children: [
568
+ " \u2717 ",
569
+ searchError
570
+ ] }) : null,
571
+ /* @__PURE__ */ jsx(Box, { paddingLeft: 2, marginTop: searching || searchError ? 0 : 1, children: /* @__PURE__ */ jsx(Tabs, { active: tab, items: tabItems }) }),
572
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 1, height: size, paddingRight: 1, children: legend ? [...LEGEND, tabLegendLine(model)].map(([glyph, meaning]) => /* @__PURE__ */ jsxs(Box, { paddingLeft: 4, children: [
573
+ /* @__PURE__ */ jsx(Box, { width: 28, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.accent, children: glyph }) }),
574
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: meaning })
575
+ ] }, glyph)) : issues.length === 0 ? /* @__PURE__ */ jsx(Box, { paddingLeft: 4, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: emptyHint() }) }) : blocks.slice(start, end).map(
576
+ (block, i) => block.kind === "gap" ? /* @__PURE__ */ jsx(Text, { children: " " }, `gap:${start + i}`) : block.kind === "fence" ? /* @__PURE__ */ jsx(Box, { paddingLeft: 4, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: rule(
577
+ block.key ? `${block.summary} \xB7 ${block.key}` : block.summary,
578
+ width - 7
579
+ ) }) }, `f:${block.key ?? "none"}`) : /* @__PURE__ */ jsxs(SelectableRow, { active: start + i === focus, children: [
580
+ block.depth === 1 ? /* @__PURE__ */ jsx(Box, { flexShrink: 0, width: 3, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2514\u2500 " }) }) : null,
581
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, width: 2, children: /* @__PURE__ */ jsx(
582
+ Text,
583
+ {
584
+ color: priorityGlyph(block.row.priority) === "\u25B2" ? colors.warning : colors.muted,
585
+ children: priorityGlyph(block.row.priority)
586
+ }
587
+ ) }),
588
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, width: keyWidth + 2, children: /* @__PURE__ */ jsx(Text, { color: colors.accent, bold: start + i === focus, children: block.row.key }) }),
589
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, width: typeWidth + 2, children: /* @__PURE__ */ jsx(Pill, { tone: "soft", variant: pillVariantFor(block.row), children: block.row.type.toLowerCase() }) }),
590
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1, children: /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", bold: start + i === focus, children: block.row.summary }) }),
591
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, width: 5, justifyContent: "flex-end", children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: relativeAge(block.row.updated, now) }) })
592
+ ] }, block.row.key)
593
+ ) }),
594
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, paddingLeft: 2, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: issues.length ? `${cursor + 1}/${issues.length}` : " " }) })
595
+ ] });
596
+ if (frame) return frame({ facts, hints, body });
597
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
598
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: facts }),
599
+ body,
600
+ /* @__PURE__ */ jsx(FooterHints, { hints })
601
+ ] });
602
+ };
603
+
604
+ // src/lib/board-mock.ts
605
+ var CHECKOUT = { key: "SHOP-300", summary: "Basket and checkout correctness" };
606
+ var STOREFRONT = { key: "SHOP-350", summary: "Storefront refresh" };
607
+ var MOCK_BOARD_ROWS = [
608
+ {
609
+ key: "SHOP-300",
610
+ summary: CHECKOUT.summary,
611
+ status: "To Do",
612
+ statusId: "10000",
613
+ category: "new",
614
+ type: "Epic",
615
+ container: true,
616
+ priority: "Medium",
617
+ assignee: "Ada Okafor",
618
+ updated: "2026-08-14T10:00:00.000Z"
619
+ },
620
+ {
621
+ key: "SHOP-412",
622
+ summary: "Checkout total ignores the discount on the last item",
623
+ status: "In Progress",
624
+ statusId: "10001",
625
+ category: "indeterminate",
626
+ type: "Bug",
627
+ container: false,
628
+ priority: "High",
629
+ parent: CHECKOUT,
630
+ assignee: "Ada Okafor",
631
+ updated: "2026-08-14T09:12:00.000Z"
632
+ },
633
+ {
634
+ key: "SHOP-408",
635
+ summary: "Add a dark theme to the storefront",
636
+ status: "In Review",
637
+ statusId: "10002",
638
+ category: "indeterminate",
639
+ type: "Story",
640
+ container: false,
641
+ priority: "Medium",
642
+ parent: STOREFRONT,
643
+ assignee: "Ada Okafor",
644
+ updated: "2026-08-13T16:40:00.000Z"
645
+ },
646
+ {
647
+ key: "SHOP-397",
648
+ summary: "Search returns stale results after a filter change",
649
+ status: "To Do",
650
+ statusId: "10000",
651
+ category: "new",
652
+ type: "Bug",
653
+ container: false,
654
+ priority: "Medium",
655
+ parent: CHECKOUT,
656
+ assignee: "Ada Okafor",
657
+ updated: "2026-08-11T08:00:00.000Z"
658
+ },
659
+ {
660
+ key: "SHOP-401",
661
+ summary: "Coupon field accepts whitespace-only codes",
662
+ status: "In Progress",
663
+ statusId: "10001",
664
+ category: "indeterminate",
665
+ type: "Bug",
666
+ container: false,
667
+ priority: "Low",
668
+ parent: CHECKOUT,
669
+ assignee: "Ada Okafor",
670
+ updated: "2026-08-12T11:30:00.000Z"
671
+ },
672
+ {
673
+ key: "PLAT-88",
674
+ summary: "Rotate the staging database credentials",
675
+ status: "Blocked",
676
+ statusId: "10005",
677
+ category: "indeterminate",
678
+ type: "Task",
679
+ container: false,
680
+ priority: "Highest",
681
+ assignee: "Ada Okafor",
682
+ updated: "2026-08-09T14:05:00.000Z"
683
+ },
684
+ {
685
+ key: "SHOP-390",
686
+ summary: "Show the VAT breakdown on the order summary",
687
+ status: "Done",
688
+ statusId: "10003",
689
+ category: "done",
690
+ type: "Story",
691
+ container: false,
692
+ priority: "Medium",
693
+ parent: CHECKOUT,
694
+ assignee: "Ada Okafor",
695
+ updated: "2026-08-08T10:00:00.000Z"
696
+ }
697
+ ];
698
+ var mockBoard = () => ({
699
+ rows: MOCK_BOARD_ROWS,
700
+ tabs: tabsFromCategories()
701
+ });
226
702
 
227
- export { IssueBody, IssueDetailView, issueDetailOf, transitionsOf };
703
+ export { IssueBoard, IssueBody, IssueDetailView, MOCK_BOARD_ROWS, OFF_BOARD, blockIndexOfIssue, blocksFor, boardOf, countsFor, issueDetailOf, mockBoard, pillVariantFor, priorityGlyph, relativeAge, tabOf, tabsFromBoard, tabsFromCategories, tabsFromConfig, toBoardRow, transitionsOf, visibleTabs };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kud/jira-ink",
3
- "version": "0.2.1",
4
- "description": "Ink components for viewing Jira issues: the issue detail screen, shared by jira-cli's TUI and cockpit",
3
+ "version": "0.3.0",
4
+ "description": "Ink components for viewing Jira issues: the board and the issue detail screen, shared by jira-cli's TUI and cockpit",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -29,11 +29,12 @@
29
29
  "react": ">=19"
30
30
  },
31
31
  "dependencies": {
32
- "@kud/ink-ui": "0.25.0",
33
32
  "@kud/ink-markdown": "0.3.0",
34
- "@kud/jira": "0.3.0"
33
+ "@kud/ink-ui": "0.25.0",
34
+ "@kud/jira": "0.4.0"
35
35
  },
36
36
  "devDependencies": {
37
+ "@kud/cli-testing": "0.1.0",
37
38
  "@types/node": "26.5.0",
38
39
  "@types/react": "19.2.18",
39
40
  "ink": "7.1.1",