@rness/cli 0.18.1 → 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/README.md +122 -9
- package/dist/bin/rness.js +1 -1
- package/dist/{chunk-JHU2G7K7.js → chunk-WIZHU4WT.js} +677 -293
- package/dist/chunk-WIZHU4WT.js.map +1 -0
- package/dist/index.js +1 -1
- package/mod/.claude-plugin/plugin.json +21 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/register.tsx +499 -0
- package/mod/types/index.d.ts +49 -0
- package/package.json +5 -1
- package/dist/chunk-JHU2G7K7.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
|
+
"name": "rness",
|
|
4
|
+
"description": "The rness workspace in Claude Code: /rness:status, /rness:adr, /rness:spec, /rness:plan.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "rness-dev",
|
|
7
|
+
"url": "https://rness.dev"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://rness.dev",
|
|
10
|
+
"repository": "https://github.com/rness-dev/rness",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"rness",
|
|
14
|
+
"agents",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"adr",
|
|
17
|
+
"specification",
|
|
18
|
+
"plan"
|
|
19
|
+
],
|
|
20
|
+
"types": "./types/index.d.ts"
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { RnessRow, RnessSnapshot } from '../types'
|
|
5
|
+
|
|
6
|
+
// The rness plugin's mod (spec 0029 §3): the workspace on screen, drawn from
|
|
7
|
+
// what the pinned CLI prints (`rness status --json`). Nothing is computed
|
|
8
|
+
// here: the CLI words every line, the module only draws and refreshes.
|
|
9
|
+
//
|
|
10
|
+
// `sync` writes this file whole into `.claude/skills/rness/hooks/`, at the
|
|
11
|
+
// workspace root and at the root of each clone, with `RNESS` set to the path
|
|
12
|
+
// of `.rness` from that place, as the settings hooks have it. One path, never
|
|
13
|
+
// a guess: a clone runs the workspace's pinned copy, never one its own tree
|
|
14
|
+
// could hold.
|
|
15
|
+
|
|
16
|
+
const snapshot = atom({ plugin: 'rness', key: 'snapshot' } as const, null)
|
|
17
|
+
const tab = atom({ plugin: 'rness', key: 'tab' } as const, 0)
|
|
18
|
+
const selected = atom({ plugin: 'rness', key: 'selected' } as const, 0)
|
|
19
|
+
const interactive = atom(
|
|
20
|
+
{ plugin: 'rness', key: 'interactive' } as const,
|
|
21
|
+
false
|
|
22
|
+
)
|
|
23
|
+
const open = atom({ plugin: 'rness', key: 'open' } as const, null)
|
|
24
|
+
|
|
25
|
+
const PANE = 'rness'
|
|
26
|
+
const BIN = 'node_modules/@rness/cli/dist/bin/rness.js'
|
|
27
|
+
/** `.rness` from the project directory; `sync` writes `../../.rness` in a clone. */
|
|
28
|
+
const RNESS = '.rness'
|
|
29
|
+
const STATE = /[\\/]\.rness[\\/]/
|
|
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
|
+
|
|
50
|
+
/** The project directory: the plugin lives in its `.claude/skills/rness`. */
|
|
51
|
+
const projectOf = (root: string): string =>
|
|
52
|
+
root.replace(/[\\/]\.claude[\\/]skills[\\/]rness[\\/]?$/, '')
|
|
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
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reads the snapshot from the pinned CLI and keeps it; the status line
|
|
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.
|
|
85
|
+
*/
|
|
86
|
+
async function refresh($: EngineInterface): Promise<RnessSnapshot | null> {
|
|
87
|
+
let next: RnessSnapshot | null = null
|
|
88
|
+
try {
|
|
89
|
+
const project = projectOf($.plugin.root)
|
|
90
|
+
const bin = `${project}/${RNESS}/${BIN}`
|
|
91
|
+
if (await $.fs.exists(bin)) {
|
|
92
|
+
const ran = await $.process.run(
|
|
93
|
+
['node', bin, 'status', '--json', '--cwd', project],
|
|
94
|
+
{ cwd: project, timeoutMs: 20_000 }
|
|
95
|
+
)
|
|
96
|
+
if (ran.exitCode === 0) next = JSON.parse(ran.stdout) as RnessSnapshot
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
next = null
|
|
100
|
+
}
|
|
101
|
+
await update($, snapshot, () => next)
|
|
102
|
+
$.ui.status(next?.statusLine)
|
|
103
|
+
const opened = await openNow($)
|
|
104
|
+
if (opened !== null) await show($, opened.path)
|
|
105
|
+
return next
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A refresh off the dispatch that asked for it: a turn never waits for node. */
|
|
109
|
+
const later = ($: EngineInterface): void => {
|
|
110
|
+
$.clock.after(0, () => void refresh($))
|
|
111
|
+
}
|
|
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
|
+
|
|
209
|
+
export const register: Register = (on) => {
|
|
210
|
+
on('session.start', async ($, e, next) => {
|
|
211
|
+
await update($, interactive, () => e.isInteractive)
|
|
212
|
+
later($)
|
|
213
|
+
return next(e)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// An edit of `.rness/` changes what the band and the pane show.
|
|
217
|
+
on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
|
|
218
|
+
const ran = await next(e)
|
|
219
|
+
if (STATE.test(e.file_path)) later($)
|
|
220
|
+
return ran
|
|
221
|
+
})
|
|
222
|
+
on('tool.call', { tool: 'Write' }, async ($, e, next) => {
|
|
223
|
+
const ran = await next(e)
|
|
224
|
+
if (STATE.test(e.file_path)) later($)
|
|
225
|
+
return ran
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// What no tool shows: a `git pull`, a file written through Bash.
|
|
229
|
+
on('turn.complete', async ($, e, next) => {
|
|
230
|
+
if (e.agentId === undefined) later($)
|
|
231
|
+
return next(e)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// The notes to act on, whenever there are some (plan 0042). The banner is
|
|
235
|
+
// the session-start line's, said once in the transcript: drawn here too,
|
|
236
|
+
// it said the same thing twice.
|
|
237
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
238
|
+
const shown = await read($, snapshot)
|
|
239
|
+
if (e.props.hasSurvey || shown === null || shown.notes.length === 0)
|
|
240
|
+
return next(e)
|
|
241
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
242
|
+
return (
|
|
243
|
+
<Box flexDirection="column">
|
|
244
|
+
{shown.notes.map((note, i) => (
|
|
245
|
+
<Box key={`note-${i}`}>
|
|
246
|
+
<Text color="yellow" wrap="truncate-end">
|
|
247
|
+
{note}
|
|
248
|
+
</Text>
|
|
249
|
+
</Box>
|
|
250
|
+
))}
|
|
251
|
+
</Box>
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// `/rness:status` is the skill's name: answered here with the pane, it
|
|
256
|
+
// runs no model turn; with no snapshot, or no one to see a pane, the
|
|
257
|
+
// skill's tables answer it.
|
|
258
|
+
on('command.run', { command: 'rness:status' }, async ($, e, next) => {
|
|
259
|
+
// Nobody to see a pane (`claude -p`): the tables are the answer.
|
|
260
|
+
if (!(await $.state.get(INTERACTIVE)).value) return next(e)
|
|
261
|
+
const shown = (await snapshotNow($)) ?? (await refresh($))
|
|
262
|
+
if (shown === null) return next(e)
|
|
263
|
+
const wanted = e.args.trim().toLowerCase()
|
|
264
|
+
const index = shown.tabs.findIndex(
|
|
265
|
+
(t) => t.name.toLowerCase() === wanted || t.label.toLowerCase() === wanted
|
|
266
|
+
)
|
|
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).
|
|
275
|
+
const opened = await $.ui.open({
|
|
276
|
+
id: PANE,
|
|
277
|
+
title: `${shown.workspace} · status`,
|
|
278
|
+
focus: true,
|
|
279
|
+
closeOnEscape: true,
|
|
280
|
+
})
|
|
281
|
+
// A surface that places no pane (an older desktop): the tables instead.
|
|
282
|
+
if (!opened.isPlaced) return next(e)
|
|
283
|
+
return {
|
|
284
|
+
text: 'The status of the workspace is open beside the conversation.',
|
|
285
|
+
}
|
|
286
|
+
})
|
|
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
|
+
|
|
344
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
345
|
+
const { Box, Button, Link, Markdown, Text } = $.ui.resolve(e)
|
|
346
|
+
const shown = await read($, snapshot)
|
|
347
|
+
if (shown === null)
|
|
348
|
+
return <Text dimColor>rness: the workspace status cannot be read.</Text>
|
|
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))
|
|
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)
|
|
405
|
+
return (
|
|
406
|
+
<Box flexDirection="column">
|
|
407
|
+
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
|
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
|
+
)),
|
|
422
|
+
<Button
|
|
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
|
+
]}
|
|
437
|
+
</Box>
|
|
438
|
+
{rows.length === 0 ? (
|
|
439
|
+
<Box key="empty">
|
|
440
|
+
<Text dimColor>Nothing here yet.</Text>
|
|
441
|
+
</Box>
|
|
442
|
+
) : (
|
|
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
|
+
})
|
|
490
|
+
)}
|
|
491
|
+
{Array.from({ length: pad }, (_, i) => (
|
|
492
|
+
<Box key={`pad-${i}`}>
|
|
493
|
+
<Text> </Text>
|
|
494
|
+
</Box>
|
|
495
|
+
))}
|
|
496
|
+
</Box>
|
|
497
|
+
)
|
|
498
|
+
})
|
|
499
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The values the rness mod keeps in `$.state` (spec 0029 §3.2). The
|
|
2
|
+
// snapshot is what `rness status --json` prints: `StatusSnapshot` in
|
|
3
|
+
// `src/commands/snapshot.ts`, read here as data.
|
|
4
|
+
|
|
5
|
+
export type RnessRow = {
|
|
6
|
+
id: string
|
|
7
|
+
title: string
|
|
8
|
+
status: string | null
|
|
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
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RnessTab = { name: string; label: string; rows: RnessRow[] }
|
|
17
|
+
|
|
18
|
+
export type RnessSnapshot = {
|
|
19
|
+
version: string
|
|
20
|
+
workspace: string
|
|
21
|
+
scope: string | null
|
|
22
|
+
banner: string
|
|
23
|
+
statusLine: string
|
|
24
|
+
inProgress: string[]
|
|
25
|
+
notes: string[]
|
|
26
|
+
/** Agent Pulse, the board; null while none is declared. */
|
|
27
|
+
pulse: string | null
|
|
28
|
+
tabs: RnessTab[]
|
|
29
|
+
}
|
|
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
|
+
|
|
34
|
+
declare module 'claude-code' {
|
|
35
|
+
interface PluginState {
|
|
36
|
+
rness: {
|
|
37
|
+
/** The last snapshot; null while none could be read. */
|
|
38
|
+
snapshot: RnessSnapshot | null
|
|
39
|
+
/** The pane's tab, by its index in the snapshot. */
|
|
40
|
+
tab: number
|
|
41
|
+
/** The selected row of that tab, by its index: ↑/↓ move it, Enter opens it. */
|
|
42
|
+
selected: number
|
|
43
|
+
/** Whether someone watches the session: no pane for `claude -p`. */
|
|
44
|
+
interactive: boolean
|
|
45
|
+
/** The document open in the pane; null while the list shows. */
|
|
46
|
+
open: RnessOpen | null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rness/cli",
|
|
3
|
-
"version": "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",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
25
25
|
"scaffold",
|
|
26
|
+
"mod/.claude-plugin/plugin.json",
|
|
27
|
+
"mod/hooks/hooks.json",
|
|
28
|
+
"mod/hooks/register.tsx",
|
|
29
|
+
"mod/types/index.d.ts",
|
|
26
30
|
"README.md",
|
|
27
31
|
"LICENSE"
|
|
28
32
|
],
|