@rness/cli 0.19.0 → 0.20.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.
- package/README.md +62 -16
- package/dist/bin/rness.js +1 -1
- package/dist/{chunk-U5SBNWVD.js → chunk-2AFAQO5B.js} +29 -7
- package/dist/chunk-2AFAQO5B.js.map +1 -0
- package/dist/index.js +1 -1
- package/mod/hooks/register.tsx +373 -45
- package/mod/types/index.d.ts +13 -0
- package/package.json +1 -1
- package/dist/chunk-U5SBNWVD.js.map +0 -1
package/dist/index.js
CHANGED
package/mod/hooks/register.tsx
CHANGED
|
@@ -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
|
+
|
|
33
57
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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 footer's label
|
|
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
|
|
@@ -40,9 +89,13 @@ async function refresh($: EngineInterface): Promise<RnessSnapshot | null> {
|
|
|
40
89
|
const project = projectOf($.plugin.root)
|
|
41
90
|
const bin = `${project}/${RNESS}/${BIN}`
|
|
42
91
|
if (await $.fs.exists(bin)) {
|
|
92
|
+
// `RNESS_NO_DELEGATE`: a pin that drifts is reported (a note, in the
|
|
93
|
+
// band), never installed from inside the session, as the settings
|
|
94
|
+
// hooks have it (spec 0015 §3). `status` is not among the commands
|
|
95
|
+
// catch-up spares, and its install would replace a local copy.
|
|
43
96
|
const ran = await $.process.run(
|
|
44
97
|
['node', bin, 'status', '--json', '--cwd', project],
|
|
45
|
-
{ cwd: project, timeoutMs: 20_000 }
|
|
98
|
+
{ cwd: project, timeoutMs: 20_000, env: { RNESS_NO_DELEGATE: '1' } }
|
|
46
99
|
)
|
|
47
100
|
if (ran.exitCode === 0) next = JSON.parse(ran.stdout) as RnessSnapshot
|
|
48
101
|
}
|
|
@@ -50,7 +103,8 @@ async function refresh($: EngineInterface): Promise<RnessSnapshot | null> {
|
|
|
50
103
|
next = null
|
|
51
104
|
}
|
|
52
105
|
await update($, snapshot, () => next)
|
|
53
|
-
|
|
106
|
+
const opened = await openNow($)
|
|
107
|
+
if (opened !== null) await show($, opened.path)
|
|
54
108
|
return next
|
|
55
109
|
}
|
|
56
110
|
|
|
@@ -59,6 +113,102 @@ const later = ($: EngineInterface): void => {
|
|
|
59
113
|
$.clock.after(0, () => void refresh($))
|
|
60
114
|
}
|
|
61
115
|
|
|
116
|
+
/** The document at `path` in `.rness/`, read and shown in place of the list. */
|
|
117
|
+
async function show($: EngineInterface, path: string): Promise<void> {
|
|
118
|
+
let text: string | null
|
|
119
|
+
try {
|
|
120
|
+
text = await $.fs.read(`${projectOf($.plugin.root)}/${RNESS}/${path}`)
|
|
121
|
+
} catch {
|
|
122
|
+
text = null
|
|
123
|
+
}
|
|
124
|
+
await update($, open, () => ({ path, text }))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The ring onto the row at `path`; a site without the keys refuses, and that is fine. */
|
|
128
|
+
const ringOn = ($: EngineInterface, path: string): void => {
|
|
129
|
+
void $.ui.focus({ requestId: PANE, key: `open-${path}` }).catch(() => {})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Shows tab `i`: its newest row selected, the pane at its top, the ring on
|
|
134
|
+
* that row — from a digit, a click, or Tab and Shift+Tab (`cycleTab`).
|
|
135
|
+
*/
|
|
136
|
+
async function showTab($: EngineInterface, i: number): Promise<void> {
|
|
137
|
+
const shown = await snapshotNow($)
|
|
138
|
+
await update($, tab, () => i)
|
|
139
|
+
await update($, selected, () => 0)
|
|
140
|
+
void $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
|
|
141
|
+
const first = shown?.tabs[i]?.rows[0]
|
|
142
|
+
if (first !== undefined) ringOn($, first.path)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The next tab (`+1`) or the previous (`-1`), wrapping. */
|
|
146
|
+
async function cycleTab($: EngineInterface, by: 1 | -1): Promise<void> {
|
|
147
|
+
const shown = await snapshotNow($)
|
|
148
|
+
const n = shown?.tabs.length ?? 0
|
|
149
|
+
if (n === 0) return
|
|
150
|
+
const at = await tabNow($)
|
|
151
|
+
await showTab($, (at + by + n) % n)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The row of the document at `path`, whichever tab lists it. */
|
|
155
|
+
const rowOf = (shown: RnessSnapshot, path: string): RnessRow | undefined =>
|
|
156
|
+
shown.tabs.flatMap((t) => t.rows).find((row) => row.path === path)
|
|
157
|
+
|
|
158
|
+
/** `text` cut to `max` cells with an ellipsis, as the full-screen view cuts a title. */
|
|
159
|
+
const cut = (text: string, max: number): string =>
|
|
160
|
+
text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The document without its front matter, as its issue's body starts (spec
|
|
164
|
+
* 0018 §3): the CLI's `stripFrontMatter`, the blank lines after it included.
|
|
165
|
+
*/
|
|
166
|
+
const bodyOf = (text: string): string =>
|
|
167
|
+
text
|
|
168
|
+
.replace(/^\uFEFF/, '')
|
|
169
|
+
.replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---(?:\r?\n|$)/, '')
|
|
170
|
+
.replace(/^(?:[ \t]*\r?\n)+/, '')
|
|
171
|
+
|
|
172
|
+
/** A line opening or closing a code fence, in a paragraph. */
|
|
173
|
+
const FENCES = /^(`{3,}|~{3,})/gm
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `text` in pieces a `Markdown` can draw: cut at a blank line, a fenced block
|
|
177
|
+
* kept whole; a paragraph or a block longer than a piece is cut as it is. A
|
|
178
|
+
* control character other than tab and newline is dropped: a `Markdown`
|
|
179
|
+
* refuses it.
|
|
180
|
+
*/
|
|
181
|
+
function pieces(text: string): string[] {
|
|
182
|
+
const out: string[] = []
|
|
183
|
+
let piece = ''
|
|
184
|
+
const put = (unit: string): void => {
|
|
185
|
+
if (piece !== '' && piece.length + 2 + unit.length > PIECE) {
|
|
186
|
+
out.push(piece)
|
|
187
|
+
piece = ''
|
|
188
|
+
}
|
|
189
|
+
piece = piece === '' ? unit : `${piece}\n\n${unit}`
|
|
190
|
+
while (piece.length > PIECE) {
|
|
191
|
+
out.push(piece.slice(0, PIECE))
|
|
192
|
+
piece = piece.slice(PIECE)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let block: string | null = null
|
|
196
|
+
for (const para of text.replace(/[^\P{Cc}\t\n]/gu, '').split('\n\n')) {
|
|
197
|
+
const opensOrCloses = (para.match(FENCES) ?? []).length % 2 === 1
|
|
198
|
+
if (block !== null) {
|
|
199
|
+
block = `${block}\n\n${para}`
|
|
200
|
+
if (opensOrCloses) {
|
|
201
|
+
put(block)
|
|
202
|
+
block = null
|
|
203
|
+
}
|
|
204
|
+
} else if (opensOrCloses) block = para
|
|
205
|
+
else put(para)
|
|
206
|
+
}
|
|
207
|
+
if (block !== null) put(block)
|
|
208
|
+
if (piece !== '') out.push(piece)
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
62
212
|
export const register: Register = (on) => {
|
|
63
213
|
on('session.start', async ($, e, next) => {
|
|
64
214
|
await update($, interactive, () => e.isInteractive)
|
|
@@ -105,25 +255,40 @@ export const register: Register = (on) => {
|
|
|
105
255
|
)
|
|
106
256
|
})
|
|
107
257
|
|
|
258
|
+
// The workspace's label among the session modes at the right of the
|
|
259
|
+
// prompt footer (plan 0044): `rness · <scope> · <n> in progress`, worded
|
|
260
|
+
// by the CLI, after the engine's own (`focus`). A mode is dim and carries
|
|
261
|
+
// no sign; `$.ui.status` would draw it as a warning. No snapshot: the
|
|
262
|
+
// footer as the engine has it.
|
|
263
|
+
on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
|
|
264
|
+
const shown = await read($, snapshot)
|
|
265
|
+
if (shown === null) return next(e)
|
|
266
|
+
return next({
|
|
267
|
+
...e,
|
|
268
|
+
props: { modes: [...e.props.modes, shown.statusLine] },
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
108
272
|
// `/rness:status` is the skill's name: answered here with the pane, it
|
|
109
273
|
// runs no model turn; with no snapshot, or no one to see a pane, the
|
|
110
274
|
// skill's tables answer it.
|
|
111
275
|
on('command.run', { command: 'rness:status' }, async ($, e, next) => {
|
|
112
276
|
// Nobody to see a pane (`claude -p`): the tables are the answer.
|
|
113
|
-
if (!(await $.state.get(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
(await $.state.get({ plugin: 'rness', key: 'snapshot' })).value ??
|
|
117
|
-
(await refresh($))
|
|
118
|
-
if (shown === null || shown === undefined) return next(e)
|
|
277
|
+
if (!(await $.state.get(INTERACTIVE)).value) return next(e)
|
|
278
|
+
const shown = (await snapshotNow($)) ?? (await refresh($))
|
|
279
|
+
if (shown === null) return next(e)
|
|
119
280
|
const wanted = e.args.trim().toLowerCase()
|
|
120
281
|
const index = shown.tabs.findIndex(
|
|
121
282
|
(t) => t.name.toLowerCase() === wanted || t.label.toLowerCase() === wanted
|
|
122
283
|
)
|
|
123
|
-
await update($, tab, (
|
|
124
|
-
// The
|
|
125
|
-
//
|
|
126
|
-
|
|
284
|
+
if (index !== -1) await update($, tab, () => index)
|
|
285
|
+
// The command asks for the list, from its first row, whatever the pane
|
|
286
|
+
// showed before.
|
|
287
|
+
await update($, selected, () => 0)
|
|
288
|
+
await update($, open, () => null)
|
|
289
|
+
// The pane takes the keys (plans 0042, 0043): ↑/↓ select a row, Enter
|
|
290
|
+
// opens it, Tab and Shift+Tab change the tab, a digit too, Esc and `q`
|
|
291
|
+
// close. A pane binds no ←/→ (Claude Code 2.1.289).
|
|
127
292
|
const opened = await $.ui.open({
|
|
128
293
|
id: PANE,
|
|
129
294
|
title: `${shown.workspace} · status`,
|
|
@@ -137,51 +302,214 @@ export const register: Register = (on) => {
|
|
|
137
302
|
}
|
|
138
303
|
})
|
|
139
304
|
|
|
305
|
+
// ↑/↓, PgUp/PgDn, Home/End are the pane's scroll keys (2.1.289): here they
|
|
306
|
+
// move the selection, and the window follows it, the selected row kept
|
|
307
|
+
// near the middle. A document open scrolls as the engine scrolls.
|
|
308
|
+
on(
|
|
309
|
+
'ui.scroll',
|
|
310
|
+
{ component: 'Pane', requestId: PANE },
|
|
311
|
+
async ($, e, next) => {
|
|
312
|
+
if (e.origin.kind !== 'person' || e.by === 0) return next(e)
|
|
313
|
+
const shown = await snapshotNow($)
|
|
314
|
+
if (shown === null || (await openNow($)) !== null) return next(e)
|
|
315
|
+
const rows = shown.tabs[await tabNow($)]?.rows ?? []
|
|
316
|
+
if (rows.length === 0) return next(e)
|
|
317
|
+
const last = rows.length - 1
|
|
318
|
+
const was = clamp(await selectedNow($), 0, last)
|
|
319
|
+
// An arrow is a row, Home and End are `contentRows`, a page key `bodyRows`.
|
|
320
|
+
const step =
|
|
321
|
+
Math.abs(e.by) <= 1
|
|
322
|
+
? e.by
|
|
323
|
+
: Math.abs(e.by) >= e.contentRows
|
|
324
|
+
? Math.sign(e.by) * rows.length
|
|
325
|
+
: Math.sign(e.by) * Math.max(1, e.bodyRows - 1)
|
|
326
|
+
const to = clamp(was + step, 0, last)
|
|
327
|
+
await update($, selected, () => to)
|
|
328
|
+
ringOn($, rows[to]!.path)
|
|
329
|
+
const header =
|
|
330
|
+
e.contentRows - rows.length - padOf(e.bodyRows, rows.length)
|
|
331
|
+
const half = Math.floor((e.bodyRows - 1) / 2)
|
|
332
|
+
const offset = clamp(
|
|
333
|
+
header + to - half,
|
|
334
|
+
0,
|
|
335
|
+
Math.max(0, e.contentRows - e.bodyRows)
|
|
336
|
+
)
|
|
337
|
+
return next({ ...e, offset })
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
// Tab and Shift+Tab walk the ring: from the selected row they land on the
|
|
342
|
+
// `›` and `‹` beside it, drawn for that. The ring stays on the row and the
|
|
343
|
+
// tab changes, as Tab does in the full-screen `rness status`. A click on
|
|
344
|
+
// another row makes it the selection; the press that follows opens it.
|
|
345
|
+
on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
|
|
346
|
+
if (e.origin.kind !== 'person' || e.element === undefined) return next(e)
|
|
347
|
+
if (e.element === 'tab-next' || e.element === 'tab-prev') {
|
|
348
|
+
await cycleTab($, e.element === 'tab-next' ? 1 : -1)
|
|
349
|
+
return {}
|
|
350
|
+
}
|
|
351
|
+
if (e.element.startsWith('open-')) {
|
|
352
|
+
const path = e.element.slice('open-'.length)
|
|
353
|
+
const shown = await snapshotNow($)
|
|
354
|
+
const rows = shown?.tabs[await tabNow($)]?.rows ?? []
|
|
355
|
+
const i = rows.findIndex((row) => row.path === path)
|
|
356
|
+
if (i !== -1) await update($, selected, () => i)
|
|
357
|
+
}
|
|
358
|
+
return next(e)
|
|
359
|
+
})
|
|
360
|
+
|
|
140
361
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
141
|
-
const { Box, Button, Text } = $.ui.resolve(e)
|
|
362
|
+
const { Box, Button, Link, Markdown, Text } = $.ui.resolve(e)
|
|
142
363
|
const shown = await read($, snapshot)
|
|
143
364
|
if (shown === null)
|
|
144
365
|
return <Text dimColor>rness: the workspace status cannot be read.</Text>
|
|
145
|
-
const
|
|
366
|
+
const opened = await read($, open)
|
|
367
|
+
|
|
368
|
+
// One document, in place of the list: `q` brings the list back, Esc
|
|
369
|
+
// still closes the pane. Its item on Agent Pulse is a link.
|
|
370
|
+
if (opened !== null) {
|
|
371
|
+
const row = rowOf(shown, opened.path)
|
|
372
|
+
return (
|
|
373
|
+
<Box flexDirection="column">
|
|
374
|
+
<Box flexDirection="row" gap={1}>
|
|
375
|
+
{[
|
|
376
|
+
<Button
|
|
377
|
+
key="back"
|
|
378
|
+
label="Back"
|
|
379
|
+
plain
|
|
380
|
+
hotkey="q"
|
|
381
|
+
autoFocus
|
|
382
|
+
onPress={() => update($, open, () => null)}
|
|
383
|
+
/>,
|
|
384
|
+
...(row?.link != null
|
|
385
|
+
? [<Link key="pulse" href={row.link} label="Agent Pulse" />]
|
|
386
|
+
: []),
|
|
387
|
+
]}
|
|
388
|
+
</Box>
|
|
389
|
+
<Box key="head">
|
|
390
|
+
{row === undefined ? (
|
|
391
|
+
<Text bold wrap="truncate-end">
|
|
392
|
+
{opened.path}
|
|
393
|
+
</Text>
|
|
394
|
+
) : (
|
|
395
|
+
<Text wrap="truncate-end">
|
|
396
|
+
<Text bold>
|
|
397
|
+
{row.id} {row.title}
|
|
398
|
+
</Text>{' '}
|
|
399
|
+
<Text color={COLORS[row.color]}>{row.status ?? '?'}</Text>
|
|
400
|
+
</Text>
|
|
401
|
+
)}
|
|
402
|
+
</Box>
|
|
403
|
+
{opened.text === null ? (
|
|
404
|
+
<Box key="unread">
|
|
405
|
+
<Text dimColor>rness: {opened.path} cannot be read.</Text>
|
|
406
|
+
</Box>
|
|
407
|
+
) : (
|
|
408
|
+
pieces(bodyOf(opened.text)).map((piece, i) => (
|
|
409
|
+
<Markdown key={`doc-${i}`} text={piece} />
|
|
410
|
+
))
|
|
411
|
+
)}
|
|
412
|
+
</Box>
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const at = clamp(await read($, tab), 0, Math.max(0, shown.tabs.length - 1))
|
|
146
417
|
const current = shown.tabs[at]
|
|
418
|
+
const rows = current?.rows ?? []
|
|
419
|
+
const sel = clamp(await read($, selected), 0, Math.max(0, rows.length - 1))
|
|
420
|
+
const width = e.props.bodyColumns ?? 80
|
|
421
|
+
const pad = padOf(e.props.scroll?.bodyRows ?? 0, rows.length)
|
|
147
422
|
return (
|
|
148
423
|
<Box flexDirection="column">
|
|
149
424
|
<Box flexDirection="row" gap={1} flexWrap="wrap">
|
|
150
|
-
{
|
|
425
|
+
{[
|
|
426
|
+
...shown.tabs.map((t, i) => (
|
|
427
|
+
<Button
|
|
428
|
+
key={`tab-${t.name}`}
|
|
429
|
+
label={`${i < 9 ? `${i + 1} ` : ''}${t.label} (${t.rows.length})`}
|
|
430
|
+
{...(i < 9 ? { hotkey: String(i + 1) } : {})}
|
|
431
|
+
variant={i === at ? 'primary' : 'secondary'}
|
|
432
|
+
// The ring starts here only when the tab has no row.
|
|
433
|
+
{...(i === at && rows.length === 0
|
|
434
|
+
? { autoFocus: true as const }
|
|
435
|
+
: {})}
|
|
436
|
+
onPress={() => void showTab($, i)}
|
|
437
|
+
/>
|
|
438
|
+
)),
|
|
151
439
|
<Button
|
|
152
|
-
key=
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
hotkey="q"
|
|
167
|
-
role="dismiss"
|
|
168
|
-
onPress={() => $.ui.close({ id: PANE })}
|
|
169
|
-
/>
|
|
440
|
+
key="close"
|
|
441
|
+
label="Close"
|
|
442
|
+
plain
|
|
443
|
+
hotkey="q"
|
|
444
|
+
role="dismiss"
|
|
445
|
+
onPress={() => $.ui.close({ id: PANE })}
|
|
446
|
+
/>,
|
|
447
|
+
...(shown.pulse != null
|
|
448
|
+
? [<Link key="pulse" href={shown.pulse} label="Agent Pulse" />]
|
|
449
|
+
: []),
|
|
450
|
+
<Text key="keys" dimColor>
|
|
451
|
+
⇥ tab · ↑↓ row · ⏎ open
|
|
452
|
+
</Text>,
|
|
453
|
+
]}
|
|
170
454
|
</Box>
|
|
171
|
-
{
|
|
455
|
+
{rows.length === 0 ? (
|
|
172
456
|
<Box key="empty">
|
|
173
457
|
<Text dimColor>Nothing here yet.</Text>
|
|
174
458
|
</Box>
|
|
175
459
|
) : (
|
|
176
|
-
// Every row
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
460
|
+
// Every row, keyed by path (two documents of one date share an
|
|
461
|
+
// id): Enter or a click opens it. The selected row carries the
|
|
462
|
+
// ring and, beside it, `‹` and `›`: where Shift+Tab and Tab land.
|
|
463
|
+
rows.map((row, i) => {
|
|
464
|
+
const status = row.status ?? '?'
|
|
465
|
+
const isSel = i === sel
|
|
466
|
+
const label = cut(
|
|
467
|
+
`${row.id} ${row.title}`,
|
|
468
|
+
width - status.length - (isSel ? 5 : 3)
|
|
469
|
+
)
|
|
470
|
+
return (
|
|
471
|
+
<Box key={`row-${row.path}`} flexDirection="row" gap={1}>
|
|
472
|
+
{[
|
|
473
|
+
isSel ? (
|
|
474
|
+
<Button
|
|
475
|
+
key="tab-prev"
|
|
476
|
+
plain
|
|
477
|
+
dimColor
|
|
478
|
+
label="‹"
|
|
479
|
+
onPress={() => void cycleTab($, -1)}
|
|
480
|
+
/>
|
|
481
|
+
) : (
|
|
482
|
+
<Text> </Text>
|
|
483
|
+
),
|
|
484
|
+
<Button
|
|
485
|
+
key={`open-${row.path}`}
|
|
486
|
+
plain
|
|
487
|
+
label={label}
|
|
488
|
+
{...(isSel ? { autoFocus: true as const } : {})}
|
|
489
|
+
onPress={() => void show($, row.path)}
|
|
490
|
+
/>,
|
|
491
|
+
<Text color={COLORS[row.color]}>{status}</Text>,
|
|
492
|
+
...(isSel
|
|
493
|
+
? [
|
|
494
|
+
<Button
|
|
495
|
+
key="tab-next"
|
|
496
|
+
plain
|
|
497
|
+
dimColor
|
|
498
|
+
label="›"
|
|
499
|
+
onPress={() => void cycleTab($, 1)}
|
|
500
|
+
/>,
|
|
501
|
+
]
|
|
502
|
+
: []),
|
|
503
|
+
]}
|
|
504
|
+
</Box>
|
|
505
|
+
)
|
|
506
|
+
})
|
|
184
507
|
)}
|
|
508
|
+
{Array.from({ length: pad }, (_, i) => (
|
|
509
|
+
<Box key={`pad-${i}`}>
|
|
510
|
+
<Text> </Text>
|
|
511
|
+
</Box>
|
|
512
|
+
))}
|
|
185
513
|
</Box>
|
|
186
514
|
)
|
|
187
515
|
})
|
package/mod/types/index.d.ts
CHANGED
|
@@ -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