@rness/cli 0.19.0 → 0.20.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/dist/index.js CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  workspaceDirs,
63
63
  writeFileAtomic,
64
64
  writeManifest
65
- } from "./chunk-U5SBNWVD.js";
65
+ } from "./chunk-WIZHU4WT.js";
66
66
 
67
67
  // src/index.ts
68
68
  async function run2(argv) {
@@ -1,7 +1,7 @@
1
1
  import { atom, read, update } from 'claude-code'
2
2
  import type { EngineInterface, Register } from 'claude-code'
3
3
 
4
- import type { RnessSnapshot } from '../types'
4
+ import type { RnessRow, RnessSnapshot } from '../types'
5
5
 
6
6
  // The rness plugin's mod (spec 0029 §3): the workspace on screen, drawn from
7
7
  // what the pinned CLI prints (`rness status --json`). Nothing is computed
@@ -15,10 +15,12 @@ import type { RnessSnapshot } from '../types'
15
15
 
16
16
  const snapshot = atom({ plugin: 'rness', key: 'snapshot' } as const, null)
17
17
  const tab = atom({ plugin: 'rness', key: 'tab' } as const, 0)
18
+ const selected = atom({ plugin: 'rness', key: 'selected' } as const, 0)
18
19
  const interactive = atom(
19
20
  { plugin: 'rness', key: 'interactive' } as const,
20
21
  false
21
22
  )
23
+ const open = atom({ plugin: 'rness', key: 'open' } as const, null)
22
24
 
23
25
  const PANE = 'rness'
24
26
  const BIN = 'node_modules/@rness/cli/dist/bin/rness.js'
@@ -26,13 +28,60 @@ const BIN = 'node_modules/@rness/cli/dist/bin/rness.js'
26
28
  const RNESS = '.rness'
27
29
  const STATE = /[\\/]\.rness[\\/]/
28
30
 
31
+ /**
32
+ * Agent Pulse's option colours, which the CLI names for each row (spec 0017
33
+ * §3), as the surface names them: the board's purple and pink are the
34
+ * terminal's magenta, its orange the terminal's yellow.
35
+ */
36
+ const COLORS: Readonly<Record<string, string>> = {
37
+ gray: 'gray',
38
+ blue: 'blue',
39
+ green: 'green',
40
+ yellow: 'yellow',
41
+ orange: 'yellow',
42
+ red: 'red',
43
+ pink: 'magenta',
44
+ purple: 'magenta',
45
+ }
46
+
47
+ /** What one `Markdown` draws at most (10000 on Claude Code 2.1.289), with room. */
48
+ const PIECE = 9000
49
+
29
50
  /** The project directory: the plugin lives in its `.claude/skills/rness`. */
30
51
  const projectOf = (root: string): string =>
31
52
  root.replace(/[\\/]\.claude[\\/]skills[\\/]rness[\\/]?$/, '')
32
53
 
54
+ const clamp = (n: number, low: number, high: number): number =>
55
+ Math.min(Math.max(n, low), high)
56
+
57
+ /**
58
+ * Blank lines under the rows, so that the body is always taller than the
59
+ * pane: in a pane ↑/↓ are scroll keys (`pane:scrollUp`/`Down` on 2.1.289),
60
+ * which raise `ui.scroll` — the selection's keys here — only while there is
61
+ * something to scroll; otherwise they walk the buttons.
62
+ */
63
+ const padOf = (bodyRows: number, rows: number): number =>
64
+ Math.max(0, bodyRows + 1 - (1 + rows))
65
+
66
+ // The values as the state holds them now, read outside a drawing: each
67
+ // reference a literal, so that the engine can list what the module reads.
68
+ const SNAPSHOT = { plugin: 'rness', key: 'snapshot' } as const
69
+ const TAB = { plugin: 'rness', key: 'tab' } as const
70
+ const SELECTED = { plugin: 'rness', key: 'selected' } as const
71
+ const OPEN = { plugin: 'rness', key: 'open' } as const
72
+ const INTERACTIVE = { plugin: 'rness', key: 'interactive' } as const
73
+ const snapshotNow = async ($: EngineInterface) =>
74
+ (await $.state.get(SNAPSHOT)).value ?? null
75
+ const tabNow = async ($: EngineInterface) => (await $.state.get(TAB)).value ?? 0
76
+ const selectedNow = async ($: EngineInterface) =>
77
+ (await $.state.get(SELECTED)).value ?? 0
78
+ const openNow = async ($: EngineInterface) =>
79
+ (await $.state.get(OPEN)).value ?? null
80
+
33
81
  /**
34
82
  * Reads the snapshot from the pinned CLI and keeps it; the status line
35
- * follows. No pinned copy, or a run that fails: no snapshot, nothing drawn.
83
+ * follows, and so does the document open in the pane, read again. No
84
+ * pinned copy, or a run that fails: no snapshot, nothing drawn.
36
85
  */
37
86
  async function refresh($: EngineInterface): Promise<RnessSnapshot | null> {
38
87
  let next: RnessSnapshot | null = null
@@ -51,6 +100,8 @@ async function refresh($: EngineInterface): Promise<RnessSnapshot | null> {
51
100
  }
52
101
  await update($, snapshot, () => next)
53
102
  $.ui.status(next?.statusLine)
103
+ const opened = await openNow($)
104
+ if (opened !== null) await show($, opened.path)
54
105
  return next
55
106
  }
56
107
 
@@ -59,6 +110,102 @@ const later = ($: EngineInterface): void => {
59
110
  $.clock.after(0, () => void refresh($))
60
111
  }
61
112
 
113
+ /** The document at `path` in `.rness/`, read and shown in place of the list. */
114
+ async function show($: EngineInterface, path: string): Promise<void> {
115
+ let text: string | null
116
+ try {
117
+ text = await $.fs.read(`${projectOf($.plugin.root)}/${RNESS}/${path}`)
118
+ } catch {
119
+ text = null
120
+ }
121
+ await update($, open, () => ({ path, text }))
122
+ }
123
+
124
+ /** The ring onto the row at `path`; a site without the keys refuses, and that is fine. */
125
+ const ringOn = ($: EngineInterface, path: string): void => {
126
+ void $.ui.focus({ requestId: PANE, key: `open-${path}` }).catch(() => {})
127
+ }
128
+
129
+ /**
130
+ * Shows tab `i`: its newest row selected, the pane at its top, the ring on
131
+ * that row — from a digit, a click, or Tab and Shift+Tab (`cycleTab`).
132
+ */
133
+ async function showTab($: EngineInterface, i: number): Promise<void> {
134
+ const shown = await snapshotNow($)
135
+ await update($, tab, () => i)
136
+ await update($, selected, () => 0)
137
+ void $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
138
+ const first = shown?.tabs[i]?.rows[0]
139
+ if (first !== undefined) ringOn($, first.path)
140
+ }
141
+
142
+ /** The next tab (`+1`) or the previous (`-1`), wrapping. */
143
+ async function cycleTab($: EngineInterface, by: 1 | -1): Promise<void> {
144
+ const shown = await snapshotNow($)
145
+ const n = shown?.tabs.length ?? 0
146
+ if (n === 0) return
147
+ const at = await tabNow($)
148
+ await showTab($, (at + by + n) % n)
149
+ }
150
+
151
+ /** The row of the document at `path`, whichever tab lists it. */
152
+ const rowOf = (shown: RnessSnapshot, path: string): RnessRow | undefined =>
153
+ shown.tabs.flatMap((t) => t.rows).find((row) => row.path === path)
154
+
155
+ /** `text` cut to `max` cells with an ellipsis, as the full-screen view cuts a title. */
156
+ const cut = (text: string, max: number): string =>
157
+ text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
158
+
159
+ /**
160
+ * The document without its front matter, as its issue's body starts (spec
161
+ * 0018 §3): the CLI's `stripFrontMatter`, the blank lines after it included.
162
+ */
163
+ const bodyOf = (text: string): string =>
164
+ text
165
+ .replace(/^\uFEFF/, '')
166
+ .replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---(?:\r?\n|$)/, '')
167
+ .replace(/^(?:[ \t]*\r?\n)+/, '')
168
+
169
+ /** A line opening or closing a code fence, in a paragraph. */
170
+ const FENCES = /^(`{3,}|~{3,})/gm
171
+
172
+ /**
173
+ * `text` in pieces a `Markdown` can draw: cut at a blank line, a fenced block
174
+ * kept whole; a paragraph or a block longer than a piece is cut as it is. A
175
+ * control character other than tab and newline is dropped: a `Markdown`
176
+ * refuses it.
177
+ */
178
+ function pieces(text: string): string[] {
179
+ const out: string[] = []
180
+ let piece = ''
181
+ const put = (unit: string): void => {
182
+ if (piece !== '' && piece.length + 2 + unit.length > PIECE) {
183
+ out.push(piece)
184
+ piece = ''
185
+ }
186
+ piece = piece === '' ? unit : `${piece}\n\n${unit}`
187
+ while (piece.length > PIECE) {
188
+ out.push(piece.slice(0, PIECE))
189
+ piece = piece.slice(PIECE)
190
+ }
191
+ }
192
+ let block: string | null = null
193
+ for (const para of text.replace(/[^\P{Cc}\t\n]/gu, '').split('\n\n')) {
194
+ const opensOrCloses = (para.match(FENCES) ?? []).length % 2 === 1
195
+ if (block !== null) {
196
+ block = `${block}\n\n${para}`
197
+ if (opensOrCloses) {
198
+ put(block)
199
+ block = null
200
+ }
201
+ } else if (opensOrCloses) block = para
202
+ else put(para)
203
+ }
204
+ if (block !== null) put(block)
205
+ if (piece !== '') out.push(piece)
206
+ return out
207
+ }
208
+
62
209
  export const register: Register = (on) => {
63
210
  on('session.start', async ($, e, next) => {
64
211
  await update($, interactive, () => e.isInteractive)
@@ -110,20 +257,21 @@ export const register: Register = (on) => {
110
257
  // skill's tables answer it.
111
258
  on('command.run', { command: 'rness:status' }, async ($, e, next) => {
112
259
  // Nobody to see a pane (`claude -p`): the tables are the answer.
113
- if (!(await $.state.get({ plugin: 'rness', key: 'interactive' })).value)
114
- return next(e)
115
- const shown =
116
- (await $.state.get({ plugin: 'rness', key: 'snapshot' })).value ??
117
- (await refresh($))
118
- if (shown === null || shown === undefined) return next(e)
260
+ if (!(await $.state.get(INTERACTIVE)).value) return next(e)
261
+ const shown = (await snapshotNow($)) ?? (await refresh($))
262
+ if (shown === null) return next(e)
119
263
  const wanted = e.args.trim().toLowerCase()
120
264
  const index = shown.tabs.findIndex(
121
265
  (t) => t.name.toLowerCase() === wanted || t.label.toLowerCase() === wanted
122
266
  )
123
- await update($, tab, (i) => (index === -1 ? i : index))
124
- // The pane takes the keys (plan 0042): a digit or Tab then Enter shows a
125
- // tab, ↑/↓ scroll as in the full-screen `rness status`, Esc and `q`
126
- // close it. A pane binds no ←/→ (Claude Code 2.1.289).
267
+ if (index !== -1) await update($, tab, () => index)
268
+ // The command asks for the list, from its first row, whatever the pane
269
+ // showed before.
270
+ await update($, selected, () => 0)
271
+ await update($, open, () => null)
272
+ // The pane takes the keys (plans 0042, 0043): ↑/↓ select a row, Enter
273
+ // opens it, Tab and Shift+Tab change the tab, a digit too, Esc and `q`
274
+ // close. A pane binds no ←/→ (Claude Code 2.1.289).
127
275
  const opened = await $.ui.open({
128
276
  id: PANE,
129
277
  title: `${shown.workspace} · status`,
@@ -137,51 +285,214 @@ export const register: Register = (on) => {
137
285
  }
138
286
  })
139
287
 
288
+ // ↑/↓, PgUp/PgDn, Home/End are the pane's scroll keys (2.1.289): here they
289
+ // move the selection, and the window follows it, the selected row kept
290
+ // near the middle. A document open scrolls as the engine scrolls.
291
+ on(
292
+ 'ui.scroll',
293
+ { component: 'Pane', requestId: PANE },
294
+ async ($, e, next) => {
295
+ if (e.origin.kind !== 'person' || e.by === 0) return next(e)
296
+ const shown = await snapshotNow($)
297
+ if (shown === null || (await openNow($)) !== null) return next(e)
298
+ const rows = shown.tabs[await tabNow($)]?.rows ?? []
299
+ if (rows.length === 0) return next(e)
300
+ const last = rows.length - 1
301
+ const was = clamp(await selectedNow($), 0, last)
302
+ // An arrow is a row, Home and End are `contentRows`, a page key `bodyRows`.
303
+ const step =
304
+ Math.abs(e.by) <= 1
305
+ ? e.by
306
+ : Math.abs(e.by) >= e.contentRows
307
+ ? Math.sign(e.by) * rows.length
308
+ : Math.sign(e.by) * Math.max(1, e.bodyRows - 1)
309
+ const to = clamp(was + step, 0, last)
310
+ await update($, selected, () => to)
311
+ ringOn($, rows[to]!.path)
312
+ const header =
313
+ e.contentRows - rows.length - padOf(e.bodyRows, rows.length)
314
+ const half = Math.floor((e.bodyRows - 1) / 2)
315
+ const offset = clamp(
316
+ header + to - half,
317
+ 0,
318
+ Math.max(0, e.contentRows - e.bodyRows)
319
+ )
320
+ return next({ ...e, offset })
321
+ }
322
+ )
323
+
324
+ // Tab and Shift+Tab walk the ring: from the selected row they land on the
325
+ // `›` and `‹` beside it, drawn for that. The ring stays on the row and the
326
+ // tab changes, as Tab does in the full-screen `rness status`. A click on
327
+ // another row makes it the selection; the press that follows opens it.
328
+ on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
329
+ if (e.origin.kind !== 'person' || e.element === undefined) return next(e)
330
+ if (e.element === 'tab-next' || e.element === 'tab-prev') {
331
+ await cycleTab($, e.element === 'tab-next' ? 1 : -1)
332
+ return {}
333
+ }
334
+ if (e.element.startsWith('open-')) {
335
+ const path = e.element.slice('open-'.length)
336
+ const shown = await snapshotNow($)
337
+ const rows = shown?.tabs[await tabNow($)]?.rows ?? []
338
+ const i = rows.findIndex((row) => row.path === path)
339
+ if (i !== -1) await update($, selected, () => i)
340
+ }
341
+ return next(e)
342
+ })
343
+
140
344
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
141
- const { Box, Button, Text } = $.ui.resolve(e)
345
+ const { Box, Button, Link, Markdown, Text } = $.ui.resolve(e)
142
346
  const shown = await read($, snapshot)
143
347
  if (shown === null)
144
348
  return <Text dimColor>rness: the workspace status cannot be read.</Text>
145
- const at = Math.min(await read($, tab), Math.max(0, shown.tabs.length - 1))
349
+ const opened = await read($, open)
350
+
351
+ // One document, in place of the list: `q` brings the list back, Esc
352
+ // still closes the pane. Its item on Agent Pulse is a link.
353
+ if (opened !== null) {
354
+ const row = rowOf(shown, opened.path)
355
+ return (
356
+ <Box flexDirection="column">
357
+ <Box flexDirection="row" gap={1}>
358
+ {[
359
+ <Button
360
+ key="back"
361
+ label="Back"
362
+ plain
363
+ hotkey="q"
364
+ autoFocus
365
+ onPress={() => update($, open, () => null)}
366
+ />,
367
+ ...(row?.link != null
368
+ ? [<Link key="pulse" href={row.link} label="Agent Pulse" />]
369
+ : []),
370
+ ]}
371
+ </Box>
372
+ <Box key="head">
373
+ {row === undefined ? (
374
+ <Text bold wrap="truncate-end">
375
+ {opened.path}
376
+ </Text>
377
+ ) : (
378
+ <Text wrap="truncate-end">
379
+ <Text bold>
380
+ {row.id} {row.title}
381
+ </Text>{' '}
382
+ <Text color={COLORS[row.color]}>{row.status ?? '?'}</Text>
383
+ </Text>
384
+ )}
385
+ </Box>
386
+ {opened.text === null ? (
387
+ <Box key="unread">
388
+ <Text dimColor>rness: {opened.path} cannot be read.</Text>
389
+ </Box>
390
+ ) : (
391
+ pieces(bodyOf(opened.text)).map((piece, i) => (
392
+ <Markdown key={`doc-${i}`} text={piece} />
393
+ ))
394
+ )}
395
+ </Box>
396
+ )
397
+ }
398
+
399
+ const at = clamp(await read($, tab), 0, Math.max(0, shown.tabs.length - 1))
146
400
  const current = shown.tabs[at]
401
+ const rows = current?.rows ?? []
402
+ const sel = clamp(await read($, selected), 0, Math.max(0, rows.length - 1))
403
+ const width = e.props.bodyColumns ?? 80
404
+ const pad = padOf(e.props.scroll?.bodyRows ?? 0, rows.length)
147
405
  return (
148
406
  <Box flexDirection="column">
149
407
  <Box flexDirection="row" gap={1} flexWrap="wrap">
150
- {shown.tabs.map((t, i) => (
408
+ {[
409
+ ...shown.tabs.map((t, i) => (
410
+ <Button
411
+ key={`tab-${t.name}`}
412
+ label={`${i < 9 ? `${i + 1} ` : ''}${t.label} (${t.rows.length})`}
413
+ {...(i < 9 ? { hotkey: String(i + 1) } : {})}
414
+ variant={i === at ? 'primary' : 'secondary'}
415
+ // The ring starts here only when the tab has no row.
416
+ {...(i === at && rows.length === 0
417
+ ? { autoFocus: true as const }
418
+ : {})}
419
+ onPress={() => void showTab($, i)}
420
+ />
421
+ )),
151
422
  <Button
152
- key={`tab-${t.name}`}
153
- // Where the body has nothing to scroll, ↑/↓ walk these buttons:
154
- // only a press (Enter, a digit, a click) changes the tab.
155
- label={`${i < 9 ? `${i + 1} ` : ''}${t.label} (${t.rows.length})`}
156
- {...(i < 9 ? { hotkey: String(i + 1) } : {})}
157
- variant={i === at ? 'primary' : 'secondary'}
158
- {...(i === at ? { autoFocus: true as const } : {})}
159
- onPress={() => update($, tab, () => i)}
160
- />
161
- ))}
162
- <Button
163
- key="close"
164
- label="Close"
165
- plain
166
- hotkey="q"
167
- role="dismiss"
168
- onPress={() => $.ui.close({ id: PANE })}
169
- />
423
+ key="close"
424
+ label="Close"
425
+ plain
426
+ hotkey="q"
427
+ role="dismiss"
428
+ onPress={() => $.ui.close({ id: PANE })}
429
+ />,
430
+ ...(shown.pulse != null
431
+ ? [<Link key="pulse" href={shown.pulse} label="Agent Pulse" />]
432
+ : []),
433
+ <Text key="keys" dimColor>
434
+ ⇥ tab · ↑↓ row · ⏎ open
435
+ </Text>,
436
+ ]}
170
437
  </Box>
171
- {current === undefined || current.rows.length === 0 ? (
438
+ {rows.length === 0 ? (
172
439
  <Box key="empty">
173
440
  <Text dimColor>Nothing here yet.</Text>
174
441
  </Box>
175
442
  ) : (
176
- // Every row: the engine scrolls the body (↑/↓, PgUp/PgDn, Home/End).
177
- current.rows.map((row) => (
178
- <Box key={`row-${row.id}`}>
179
- <Text wrap="truncate-end">
180
- {row.id} {row.title} <Text dimColor>{row.status ?? '?'}</Text>
181
- </Text>
182
- </Box>
183
- ))
443
+ // Every row, keyed by path (two documents of one date share an
444
+ // id): Enter or a click opens it. The selected row carries the
445
+ // ring and, beside it, `‹` and `›`: where Shift+Tab and Tab land.
446
+ rows.map((row, i) => {
447
+ const status = row.status ?? '?'
448
+ const isSel = i === sel
449
+ const label = cut(
450
+ `${row.id} ${row.title}`,
451
+ width - status.length - (isSel ? 5 : 3)
452
+ )
453
+ return (
454
+ <Box key={`row-${row.path}`} flexDirection="row" gap={1}>
455
+ {[
456
+ isSel ? (
457
+ <Button
458
+ key="tab-prev"
459
+ plain
460
+ dimColor
461
+ label="‹"
462
+ onPress={() => void cycleTab($, -1)}
463
+ />
464
+ ) : (
465
+ <Text> </Text>
466
+ ),
467
+ <Button
468
+ key={`open-${row.path}`}
469
+ plain
470
+ label={label}
471
+ {...(isSel ? { autoFocus: true as const } : {})}
472
+ onPress={() => void show($, row.path)}
473
+ />,
474
+ <Text color={COLORS[row.color]}>{status}</Text>,
475
+ ...(isSel
476
+ ? [
477
+ <Button
478
+ key="tab-next"
479
+ plain
480
+ dimColor
481
+ label="›"
482
+ onPress={() => void cycleTab($, 1)}
483
+ />,
484
+ ]
485
+ : []),
486
+ ]}
487
+ </Box>
488
+ )
489
+ })
184
490
  )}
491
+ {Array.from({ length: pad }, (_, i) => (
492
+ <Box key={`pad-${i}`}>
493
+ <Text> </Text>
494
+ </Box>
495
+ ))}
185
496
  </Box>
186
497
  )
187
498
  })
@@ -7,6 +7,10 @@ export type RnessRow = {
7
7
  title: string
8
8
  status: string | null
9
9
  path: string
10
+ /** The colour of its status on Agent Pulse, by the board's name (`yellow`). */
11
+ color: string
12
+ /** Its item on Agent Pulse; null while the workspace declares no pulse. */
13
+ link: string | null
10
14
  }
11
15
 
12
16
  export type RnessTab = { name: string; label: string; rows: RnessRow[] }
@@ -19,9 +23,14 @@ export type RnessSnapshot = {
19
23
  statusLine: string
20
24
  inProgress: string[]
21
25
  notes: string[]
26
+ /** Agent Pulse, the board; null while none is declared. */
27
+ pulse: string | null
22
28
  tabs: RnessTab[]
23
29
  }
24
30
 
31
+ /** The document the pane shows in place of the list: its path, and its text, or null when it could not be read. */
32
+ export type RnessOpen = { path: string; text: string | null }
33
+
25
34
  declare module 'claude-code' {
26
35
  interface PluginState {
27
36
  rness: {
@@ -29,8 +38,12 @@ declare module 'claude-code' {
29
38
  snapshot: RnessSnapshot | null
30
39
  /** The pane's tab, by its index in the snapshot. */
31
40
  tab: number
41
+ /** The selected row of that tab, by its index: ↑/↓ move it, Enter opens it. */
42
+ selected: number
32
43
  /** Whether someone watches the session: no pane for `claude -p`. */
33
44
  interactive: boolean
45
+ /** The document open in the pane; null while the list shows. */
46
+ open: RnessOpen | null
34
47
  }
35
48
  }
36
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rness/cli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Configuration-plane CLI for rness workspaces: resolve, validate and sync org-level context for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",