jackherd 1.11.1 → 1.12.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/CHANGELOG.md +22 -0
- package/README.md +76 -58
- package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
- package/dist/config.d.ts +10 -0
- package/dist/config.js +15 -5
- package/dist/plan-format.d.ts +11 -6
- package/dist/plan-format.js +8 -2
- package/docs/follow-dark.svg +2 -2
- package/docs/follow.svg +2 -2
- package/docs/workflow-dark.svg +56 -64
- package/docs/workflow.svg +56 -64
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
- package/kit/workflow/skills/orchestrate/references/events.md +8 -1
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/package.json +2 -2
- package/schemas/jackherd.schema.json +18 -1
- package/src/config/link-kinds.ts +1 -1
- package/src/config/schema.ts +5 -2
- package/src/config/tones.ts +9 -2
- package/src/context/fill.ts +4 -10
- package/src/context/session-log.ts +108 -2
- package/src/plan-format/lanes.ts +10 -1
- package/src/plan-format/phases.ts +11 -3
- package/src/plan-format/progress.ts +4 -2
- package/src/plan-format/waiting.ts +12 -0
- package/src/plans/active-tasks.ts +31 -8
- package/src/plans/appointment-rows.ts +42 -53
- package/src/plans/board-rows.ts +14 -0
- package/src/plans/calendar-days.ts +29 -0
- package/src/plans/click-hint.ts +94 -0
- package/src/plans/command.ts +8 -9
- package/src/plans/current-phase.ts +22 -10
- package/src/plans/current-task.ts +18 -3
- package/src/plans/frame.ts +18 -26
- package/src/plans/git.ts +90 -31
- package/src/plans/groomed.ts +13 -7
- package/src/plans/lane-reason.ts +61 -0
- package/src/plans/lane-summary.ts +75 -24
- package/src/plans/layout-phase.ts +123 -76
- package/src/plans/layout-summary.ts +133 -83
- package/src/plans/layout.ts +451 -345
- package/src/plans/legend.ts +29 -0
- package/src/plans/link-chips.ts +26 -8
- package/src/plans/live.ts +238 -84
- package/src/plans/next-rows.ts +5 -37
- package/src/plans/phase-source.ts +8 -3
- package/src/plans/plan-chips.ts +6 -3
- package/src/plans/plan-heads.ts +1 -1
- package/src/plans/planned.ts +4 -1
- package/src/plans/process-slots.ts +36 -0
- package/src/plans/pull-requests.ts +9 -1
- package/src/plans/read-cache.ts +18 -3
- package/src/plans/refresh-pace.ts +13 -0
- package/src/plans/row-focus.ts +86 -15
- package/src/plans/selection.ts +124 -89
- package/src/plans/sent-files.ts +1 -1
- package/src/plans/settings.ts +5 -2
- package/src/plans/shimmer.ts +124 -0
- package/src/plans/signals-read.ts +38 -10
- package/src/plans/slice-gate.ts +51 -0
- package/src/plans/snapshot.ts +185 -84
- package/src/plans/source-asked.ts +1 -1
- package/src/plans/source-rows.ts +60 -18
- package/src/plans/sprint-html.ts +1 -1
- package/src/plans/status-pace.ts +45 -0
- package/src/plans/subagent-tasks.ts +1 -1
- package/src/plans/subagents-read.ts +61 -24
- package/src/plans/subagents.ts +2 -2
- package/src/plans/symbols.ts +4 -1
- package/src/plans/task-brief.ts +143 -0
- package/src/plans/texts.ts +181 -39
- package/src/plans/view.ts +198 -32
- package/src/plans/waiting-click.ts +54 -110
- package/src/plans/watch-burst.ts +25 -0
- package/src/plans/worktree-stamp.ts +35 -0
- package/src/plans/your-turn-rows.ts +102 -0
- package/src/plans/your-turn.ts +226 -0
- package/src/terminal/herdr.ts +25 -5
- package/src/terminal/restore.ts +44 -0
- package/src/terminal/text.ts +2 -2
- package/src/terminal/width.ts +9 -0
- package/src/vocabulary/index.ts +6 -5
- package/src/watch/command.ts +28 -2
- package/src/watch/lane-report.ts +2 -0
- package/src/watch/lane-rest.ts +7 -1
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/unlinked-artifacts-read.ts +43 -0
- package/src/watch/unlinked-artifacts.ts +76 -0
- package/src/watch/watch-events.ts +12 -0
- package/src/watch/watcher-lock.ts +47 -4
package/src/plans/selection.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { currentPhase, type LaneProgress } from '../plan-format'
|
|
2
2
|
import { AGENT_STATUS } from '../terminal/types'
|
|
3
3
|
import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
|
|
4
|
-
import { TEXTS } from './texts'
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Whether a lane is drawn open. It is decided once, when the lane first appears (`autoOpen`, by its status then), and
|
|
@@ -10,13 +9,12 @@ import { TEXTS } from './texts'
|
|
|
10
9
|
export type LaneFold = { open: boolean }
|
|
11
10
|
|
|
12
11
|
/** The blocks on top of the herd, by the name their fold is remembered under. */
|
|
13
|
-
export const TOP_BLOCK = {
|
|
12
|
+
export const TOP_BLOCK = { yourTurn: 'yourTurn', sources: 'sources' } as const
|
|
14
13
|
export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
|
-
* The row under the cursor and the expanded phases, both as keys, whether
|
|
18
|
-
*
|
|
19
|
-
* folds of the lanes by lane name.
|
|
16
|
+
* The row under the cursor and the expanded phases, both as keys, whether the footer shows the symbol legend, and
|
|
17
|
+
* the folds of the lanes by lane name.
|
|
20
18
|
*/
|
|
21
19
|
export type Selection = {
|
|
22
20
|
selected: string | null
|
|
@@ -25,11 +23,12 @@ export type Selection = {
|
|
|
25
23
|
/** The column the square-bracket button under the mouse starts in; null or absent while the mouse is over none. */
|
|
26
24
|
hoveredFrom?: number | null
|
|
27
25
|
expanded: ReadonlySet<string>
|
|
28
|
-
showDone: boolean
|
|
29
26
|
showLegend: boolean
|
|
30
27
|
laneFolds: ReadonlyMap<string, LaneFold>
|
|
31
|
-
/**
|
|
32
|
-
|
|
28
|
+
/** The top blocks the user opened; every other one is folded, at the start of the herd all of them. */
|
|
29
|
+
openBlocks?: ReadonlySet<TopBlockId>
|
|
30
|
+
/** True once the user folded the lanes block, which is open at the start; absent or false while it is open. */
|
|
31
|
+
lanesFolded?: boolean
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
/** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
|
|
@@ -58,8 +57,8 @@ export const INPUT = {
|
|
|
58
57
|
toggle: ['\r', ' '],
|
|
59
58
|
expand: ['\x1b[C', '\x1bOC', 'l'],
|
|
60
59
|
collapse: ['\x1b[D', '\x1bOD', 'h'],
|
|
61
|
-
showDone: ['a'],
|
|
62
60
|
legend: ['?'],
|
|
61
|
+
closeLegend: ['\x1b'],
|
|
63
62
|
sprintPage: ['s'],
|
|
64
63
|
pageUp: ['\x1b[5~'],
|
|
65
64
|
pageDown: ['\x1b[6~'],
|
|
@@ -76,8 +75,46 @@ export const MOUSE_BUTTON = { left: 0, wheelUp: 64, wheelDown: 65 } as const
|
|
|
76
75
|
/** One terminal read can carry several key presses; this splits it into keys, escape sequences kept whole. */
|
|
77
76
|
export const splitKeys = (chunk: string): string[] => chunk.match(ESCAPE_SEQUENCE) ?? []
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
const isMotionReport = (key: string): boolean => {
|
|
79
|
+
const mouse = parseMouse(key)
|
|
80
|
+
return mouse !== null && isMotion(mouse)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The keys of one read with every mouse motion but the last dropped: only where the mouse ended up counts, so a burst
|
|
85
|
+
* of motions is one hover, not one drawing each. Clicks, releases, the wheel and keys stay in their order.
|
|
86
|
+
*/
|
|
87
|
+
export const withoutStaleMotions = (keys: readonly string[]): string[] => {
|
|
88
|
+
const last = keys.reduce((found, key, index) => (isMotionReport(key) ? index : found), -1)
|
|
89
|
+
return keys.filter((key, index) => index === last || !isMotionReport(key))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The keys of the heads of the blocks of the body: the lanes at work, the lanes that wait for their start, and the
|
|
94
|
+
* backlog of phases no lane works on. No lane key starts without `@`, no phase key lacks a colon.
|
|
95
|
+
*/
|
|
96
|
+
const BLOCK_KEY_PREFIX = 'block>'
|
|
97
|
+
export const LANES_KEY = `${BLOCK_KEY_PREFIX}lanes`
|
|
98
|
+
export const WAITING_KEY = `${BLOCK_KEY_PREFIX}waiting`
|
|
99
|
+
export const BACKLOG_KEY = `${BLOCK_KEY_PREFIX}backlog`
|
|
100
|
+
|
|
101
|
+
/** Whether a key is the head of a block of the body. */
|
|
102
|
+
export const isBlockHeadKey = (key: string): boolean => key.startsWith(BLOCK_KEY_PREFIX)
|
|
103
|
+
|
|
104
|
+
const TOP_HEAD_KEY_PREFIX = 'head>'
|
|
105
|
+
|
|
106
|
+
/** The key of the head of a block on top, which the arrows reach like every other clickable row. */
|
|
107
|
+
export const topBlockHeadKey = (block: TopBlockId): string => `${TOP_HEAD_KEY_PREFIX}${block}`
|
|
108
|
+
|
|
109
|
+
/** The block on top whose head a key names, null for any other key. */
|
|
110
|
+
export const topBlockOfKey = (key: string): TopBlockId | null =>
|
|
111
|
+
Object.values(TOP_BLOCK).find((block) => topBlockHeadKey(block) === key) ?? null
|
|
112
|
+
|
|
113
|
+
/** Whether the lanes block is drawn open: it is at the start of the herd, until the user folds it. */
|
|
114
|
+
export const isLanesOpen = ({ lanesFolded }: Selection): boolean => !lanesFolded
|
|
115
|
+
|
|
116
|
+
const withLanesOpen = (selection: Selection, open: boolean): Selection =>
|
|
117
|
+
isLanesOpen(selection) === open ? selection : { ...selection, lanesFolded: !open }
|
|
81
118
|
|
|
82
119
|
export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
|
|
83
120
|
|
|
@@ -88,17 +125,16 @@ const PHASE_KEY = new RegExp(`^${PHASE_KEY_SHAPE}$`)
|
|
|
88
125
|
/** Whether a key names a phase: not a lane, row or task key, which carry `@`, `>` or `#`. */
|
|
89
126
|
const isPhaseKey = (key: string): boolean => PHASE_KEY.test(key)
|
|
90
127
|
|
|
128
|
+
/** The phase id a phase key names (`4` of `lane:0:4`), null for any other key. */
|
|
129
|
+
export const phaseIdOfKey = (key: string): string | null =>
|
|
130
|
+
isPhaseKey(key) ? key.slice(key.lastIndexOf(':') + 1) : null
|
|
131
|
+
|
|
91
132
|
/** The key of a lane's `next` row, which opens to the phases that wait. */
|
|
92
133
|
export const nextKey = (lane: string): string => `next>${lane}`
|
|
93
134
|
|
|
94
135
|
/** The key of a lane's `✓` row, which opens to the finished phases. */
|
|
95
136
|
export const finishedKey = (lane: string): string => `done>${lane}`
|
|
96
137
|
|
|
97
|
-
const TICKED_KEY_PREFIX = 'ticked>'
|
|
98
|
-
|
|
99
|
-
/** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
|
|
100
|
-
export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
|
|
101
|
-
|
|
102
138
|
const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
|
|
103
139
|
|
|
104
140
|
/**
|
|
@@ -108,18 +144,23 @@ const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
|
|
|
108
144
|
*/
|
|
109
145
|
export const itemKey = (phase: string, index: number): string => `${phase}#${index}`
|
|
110
146
|
|
|
147
|
+
const TASK_REST_PREFIX = 'rest>'
|
|
148
|
+
|
|
149
|
+
/** The key of the `▸ n more lines` row under an opened task, which opens the rest of its text. */
|
|
150
|
+
export const taskRestKey = (task: string): string => `${TASK_REST_PREFIX}${task}`
|
|
151
|
+
|
|
152
|
+
/** Whether a key is the rest row of an opened task. */
|
|
153
|
+
export const isTaskRestKey = (key: string): boolean => key.startsWith(TASK_REST_PREFIX)
|
|
154
|
+
|
|
111
155
|
/** The phase a task key belongs to, null for any other key. */
|
|
112
156
|
export const itemPhaseOfKey = (key: string): string | null => key.match(ITEM_KEY)?.[1] ?? null
|
|
113
157
|
|
|
114
|
-
const tickedPhaseOfKey = (key: string): string | null =>
|
|
115
|
-
key.startsWith(TICKED_KEY_PREFIX) ? key.slice(TICKED_KEY_PREFIX.length) : null
|
|
116
|
-
|
|
117
158
|
const LANE_KEY_PREFIX = '@'
|
|
118
159
|
|
|
119
160
|
/** The key of a lane's own row; it cannot collide with a phase key, which starts with a lane name and a colon. */
|
|
120
161
|
export const laneKey = (lane: string): string => `${LANE_KEY_PREFIX}${lane}`
|
|
121
162
|
|
|
122
|
-
const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFIX) ? key.slice(1) : null)
|
|
163
|
+
export const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFIX) ? key.slice(1) : null)
|
|
123
164
|
|
|
124
165
|
/** Working and blocked lanes show their phases; idle and done ones, and lanes without an agent, fold into one line. */
|
|
125
166
|
export const autoOpen = (status: string | null): boolean =>
|
|
@@ -148,7 +189,6 @@ export const syncLaneFolds = (selection: Selection, lanes: readonly LaneStatusEn
|
|
|
148
189
|
export const overviewSelection = (): Selection => ({
|
|
149
190
|
selected: null,
|
|
150
191
|
expanded: new Set(),
|
|
151
|
-
showDone: false,
|
|
152
192
|
showLegend: false,
|
|
153
193
|
laneFolds: new Map(),
|
|
154
194
|
})
|
|
@@ -164,9 +204,9 @@ export const laneSelection = (progress: LaneProgress | undefined): Selection =>
|
|
|
164
204
|
return { ...opened, selected: key, expanded: new Set([key]) }
|
|
165
205
|
}
|
|
166
206
|
|
|
167
|
-
/** The selectable rows in screen order, once each: only what is drawn can be selected; a chip
|
|
207
|
+
/** The selectable rows in screen order, once each: only what is drawn can be selected; a chip is clicked, not selected. */
|
|
168
208
|
export const targetKeys = (targets: readonly ClickTarget[]): string[] => [
|
|
169
|
-
...new Set(targets.map((target) => target.key).filter((key) => !isChipKey(key)
|
|
209
|
+
...new Set(targets.map((target) => target.key).filter((key) => !isChipKey(key))),
|
|
170
210
|
]
|
|
171
211
|
|
|
172
212
|
/** The selected key, or the first one while nothing is selected or the selected phase left the sprint. */
|
|
@@ -183,7 +223,8 @@ const move = (selection: Selection, keys: readonly string[], step: number): Sele
|
|
|
183
223
|
}
|
|
184
224
|
|
|
185
225
|
const toggleIn = (keys: Set<string>, key: string): void => {
|
|
186
|
-
if (
|
|
226
|
+
if (keys.has(key)) removeKey(keys, key)
|
|
227
|
+
else keys.add(key)
|
|
187
228
|
}
|
|
188
229
|
|
|
189
230
|
const opened = () => true
|
|
@@ -198,18 +239,15 @@ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => b
|
|
|
198
239
|
}
|
|
199
240
|
|
|
200
241
|
/**
|
|
201
|
-
* Selects the row under a click. A
|
|
202
|
-
*
|
|
242
|
+
* Selects the row under a click. A lane line flips the fold of its lane, any other row opens when closed and closes
|
|
243
|
+
* when open: a phase shows its items, a task its whole text. A task never opens its phase: the tasks shown under a
|
|
244
|
+
* folded phase stay under a folded phase.
|
|
203
245
|
*/
|
|
204
246
|
export const applyClick = (selection: Selection, key: string): Selection => {
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
const lane = laneOfKey(key)
|
|
208
|
-
if (lane !== null) return { ...foldLane(selection, lane, flipped), selected: key }
|
|
247
|
+
const head = foldedHead(selection, key, flipped)
|
|
248
|
+
if (head) return head
|
|
209
249
|
const expanded = new Set(selection.expanded)
|
|
210
|
-
|
|
211
|
-
if (itemPhase !== null && !expanded.has(itemPhase)) addWithPhase(expanded, key)
|
|
212
|
-
else toggleIn(expanded, key)
|
|
250
|
+
toggleIn(expanded, key)
|
|
213
251
|
return { ...selection, selected: key, expanded }
|
|
214
252
|
}
|
|
215
253
|
|
|
@@ -218,18 +256,33 @@ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
|
|
|
218
256
|
return current ? applyClick(selection, current) : selection
|
|
219
257
|
}
|
|
220
258
|
|
|
221
|
-
|
|
222
|
-
const addWithPhase = (expanded: Set<string>, key: string): void => {
|
|
259
|
+
const addKey = (expanded: Set<string>, key: string): void => {
|
|
223
260
|
expanded.add(key)
|
|
224
|
-
const itemPhase = itemPhaseOfKey(key)
|
|
225
|
-
if (itemPhase !== null) expanded.add(itemPhase)
|
|
226
261
|
}
|
|
227
262
|
|
|
263
|
+
/** Folds `key`, and the rest of its text when it is a task, so the next opening shows the brief again. */
|
|
228
264
|
const removeKey = (expanded: Set<string>, key: string): void => {
|
|
229
265
|
expanded.delete(key)
|
|
266
|
+
expanded.delete(taskRestKey(key))
|
|
230
267
|
}
|
|
231
268
|
|
|
232
|
-
/**
|
|
269
|
+
/**
|
|
270
|
+
* The selection with the fold of `key` changed by `next` and `key` selected, when it is a row with a fold of its own:
|
|
271
|
+
* a lane, the lanes block or a block on top. Null for any other row, which opens through the expanded keys.
|
|
272
|
+
*/
|
|
273
|
+
const foldedHead = (selection: Selection, key: string, next: (open: boolean) => boolean): Selection | null => {
|
|
274
|
+
const lane = laneOfKey(key)
|
|
275
|
+
if (lane !== null) return { ...foldLane(selection, lane, next), selected: key }
|
|
276
|
+
if (key === LANES_KEY) return { ...withLanesOpen(selection, next(isLanesOpen(selection))), selected: key }
|
|
277
|
+
const block = topBlockOfKey(key)
|
|
278
|
+
if (block === null) return null
|
|
279
|
+
return { ...withBlockOpen(selection, block, next(!isBlockFolded(selection, block))), selected: key }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Applies a change to the selected row: `nextFold` to the fold of a lane, the lanes block or a block on top, `change`
|
|
284
|
+
* to the expanded keys for any other row.
|
|
285
|
+
*/
|
|
233
286
|
const changeSelected = (
|
|
234
287
|
selection: Selection,
|
|
235
288
|
keys: readonly string[],
|
|
@@ -238,82 +291,61 @@ const changeSelected = (
|
|
|
238
291
|
): Selection => {
|
|
239
292
|
const current = selectedKey(selection, keys)
|
|
240
293
|
if (!current) return selection
|
|
241
|
-
const
|
|
242
|
-
if (
|
|
294
|
+
const head = foldedHead(selection, current, nextFold)
|
|
295
|
+
if (head) return head
|
|
243
296
|
const expanded = new Set(selection.expanded)
|
|
244
297
|
change(expanded, current)
|
|
245
298
|
return { ...selection, selected: current, expanded }
|
|
246
299
|
}
|
|
247
300
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
301
|
+
/**
|
|
302
|
+
* Whether `candidate` is a point under `key`: one of its rows (`<key>><id>`), its `+<n>` line (`more><key>`) or the
|
|
303
|
+
* rest of its text when it is a task.
|
|
304
|
+
*/
|
|
305
|
+
const isChildKey = (key: string, candidate: string): boolean =>
|
|
306
|
+
candidate.startsWith(`${key}>`) || candidate === `more>${key}` || candidate === taskRestKey(key)
|
|
254
307
|
|
|
255
308
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
309
|
+
* The selection with `key` expanded when it was folded, and folded with every point under it when it was expanded, so
|
|
310
|
+
* nothing stays open inside a folded row and comes back on the next opening; nothing else changes.
|
|
258
311
|
*/
|
|
259
|
-
export const
|
|
260
|
-
|
|
261
|
-
|
|
312
|
+
export const toggledExpansion = (selection: Selection, key: string): Selection => {
|
|
313
|
+
if (!selection.expanded.has(key)) return { ...selection, expanded: new Set(selection.expanded).add(key) }
|
|
314
|
+
const expanded = new Set([...selection.expanded].filter((open) => open !== key && !isChildKey(key, open)))
|
|
315
|
+
return { ...selection, expanded }
|
|
262
316
|
}
|
|
263
317
|
|
|
264
318
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* itself when no fold is forgotten.
|
|
319
|
+
* Whether `block` is drawn folded: every block on top starts folded, its head saying what it holds, and stays open from
|
|
320
|
+
* the user's opening to their folding, over every refresh. Nothing new opens it by itself.
|
|
268
321
|
*/
|
|
269
|
-
export const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
): Selection => {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (opened.length === 0) return selection
|
|
278
|
-
const kept = Object.fromEntries(Object.entries(folds).filter(([block]) => !opened.includes(block as TopBlockId)))
|
|
279
|
-
const { blockFolds: _forgotten, ...rest } = selection
|
|
280
|
-
return Object.keys(kept).length > 0 ? { ...rest, blockFolds: kept } : rest
|
|
322
|
+
export const isBlockFolded = ({ openBlocks }: Selection, block: TopBlockId): boolean => !openBlocks?.has(block)
|
|
323
|
+
|
|
324
|
+
/** The selection with `block` opened when it was folded, and folded when it was open. */
|
|
325
|
+
export const toggledBlockFold = (selection: Selection, block: TopBlockId): Selection => {
|
|
326
|
+
const open = new Set(selection.openBlocks)
|
|
327
|
+
if (open.has(block)) open.delete(block)
|
|
328
|
+
else open.add(block)
|
|
329
|
+
return { ...selection, openBlocks: open }
|
|
281
330
|
}
|
|
282
331
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (!isBlockFolded(selection, block, keys)) {
|
|
286
|
-
return { ...selection, blockFolds: { ...selection.blockFolds, [block]: new Set(keys) } }
|
|
287
|
-
}
|
|
288
|
-
const { blockFolds, ...rest } = selection
|
|
289
|
-
const { [block]: _folded, ...otherFolds } = blockFolds ?? {}
|
|
290
|
-
return Object.keys(otherFolds).length > 0 ? { ...rest, blockFolds: otherFolds } : rest
|
|
291
|
-
}
|
|
332
|
+
const withBlockOpen = (selection: Selection, block: TopBlockId, open: boolean): Selection =>
|
|
333
|
+
isBlockFolded(selection, block) === !open ? selection : toggledBlockFold(selection, block)
|
|
292
334
|
|
|
293
335
|
const expand = (selection: Selection, keys: readonly string[]): Selection =>
|
|
294
|
-
changeSelected(selection, keys, opened,
|
|
336
|
+
changeSelected(selection, keys, opened, addKey)
|
|
295
337
|
|
|
296
338
|
const collapse = (selection: Selection, keys: readonly string[]): Selection =>
|
|
297
339
|
changeSelected(selection, keys, closed, removeKey)
|
|
298
340
|
|
|
299
|
-
/**
|
|
300
|
-
* The line for a press of `a` that changes nothing on screen, null for any other input. The key lists the ticked
|
|
301
|
-
* tasks inside an expanded phase, so it needs one among the `keys` drawn.
|
|
302
|
-
*/
|
|
303
|
-
export const showDoneNotice = (selection: Selection, input: string, keys: readonly string[]): string | null => {
|
|
304
|
-
if (!isInput(INPUT.showDone, input)) return null
|
|
305
|
-
const phaseOpen = keys.some((key) => isPhaseKey(key) && selection.expanded.has(key))
|
|
306
|
-
return phaseOpen ? null : TEXTS.showDoneNeedsPhase
|
|
307
|
-
}
|
|
308
|
-
|
|
309
341
|
export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
|
|
310
342
|
if (isInput(INPUT.up, input)) return move(selection, keys, -1)
|
|
311
343
|
if (isInput(INPUT.down, input)) return move(selection, keys, 1)
|
|
312
344
|
if (isInput(INPUT.toggle, input)) return toggle(selection, keys)
|
|
313
345
|
if (isInput(INPUT.expand, input)) return expand(selection, keys)
|
|
314
346
|
if (isInput(INPUT.collapse, input)) return collapse(selection, keys)
|
|
315
|
-
if (isInput(INPUT.showDone, input)) return { ...selection, showDone: !selection.showDone }
|
|
316
347
|
if (isInput(INPUT.legend, input)) return { ...selection, showLegend: !selection.showLegend }
|
|
348
|
+
if (isInput(INPUT.closeLegend, input) && selection.showLegend) return { ...selection, showLegend: false }
|
|
317
349
|
return selection
|
|
318
350
|
}
|
|
319
351
|
|
|
@@ -353,7 +385,8 @@ const clicked = (mouse: MouseEvent, view: View, { targets, headerRows }: InputCo
|
|
|
353
385
|
|
|
354
386
|
/**
|
|
355
387
|
* Applies one key or mouse report. Wheel and page keys move the window and stop it following the
|
|
356
|
-
* cursor; everything that changes the selection makes it follow again
|
|
388
|
+
* cursor; everything that changes the selection makes it follow again, except opening the help, which
|
|
389
|
+
* starts at its top.
|
|
357
390
|
*/
|
|
358
391
|
export const applyInput = (view: View, input: string, context: InputContext): View => {
|
|
359
392
|
const mouse = parseMouse(input)
|
|
@@ -363,7 +396,9 @@ export const applyInput = (view: View, input: string, context: InputContext): Vi
|
|
|
363
396
|
if (mouse)
|
|
364
397
|
return key && !isChipKey(key) ? { ...view, selection: applyClick(view.selection, key), follow: true } : view
|
|
365
398
|
const selection = applyKey(view.selection, input, context.keys)
|
|
366
|
-
|
|
399
|
+
if (selection === view.selection) return view
|
|
400
|
+
if (selection.showLegend && !view.selection.showLegend) return { ...view, selection, top: 0, follow: false }
|
|
401
|
+
return { ...view, selection, follow: true }
|
|
367
402
|
}
|
|
368
403
|
|
|
369
404
|
/** The row an input acts on: enter, space or right on the selected row, or a left click; null for anything else. */
|
package/src/plans/sent-files.ts
CHANGED
|
@@ -31,7 +31,7 @@ const isAddress = (file: string): boolean => file.startsWith('https://')
|
|
|
31
31
|
const addressOf = (file: string): string =>
|
|
32
32
|
isAddress(file) ? file : `file://${file.split('/').map(encodeURIComponent).join('/')}`
|
|
33
33
|
|
|
34
|
-
/** What a badge or a
|
|
34
|
+
/** What a badge or a row of "Your turn" shows of a file, without the control characters a hand-written record may carry. */
|
|
35
35
|
const labelOf = (file: string, caption: string | null): string =>
|
|
36
36
|
terminalSafe(isAddress(file) ? (caption ?? 'artifact') : basename(file))
|
|
37
37
|
|
package/src/plans/settings.ts
CHANGED
|
@@ -14,19 +14,21 @@ export type PlansSettings = Pick<
|
|
|
14
14
|
> & {
|
|
15
15
|
adapter: JackherdConfig['terminal']['adapter']
|
|
16
16
|
fetchIntervalMinutes: number
|
|
17
|
+
/** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
|
|
18
|
+
shimmer: boolean
|
|
17
19
|
linkKinds: JackherdConfig['plans']['linkKinds']
|
|
18
20
|
context: JackherdConfig['plans']['context']
|
|
19
21
|
/** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
|
|
20
22
|
ticketSources: TicketSource[]
|
|
21
23
|
/** The other `watch.sources` by name, with the tone each names for a new report; the herd lists them in a block of their own. */
|
|
22
24
|
herdSources: { name: string; tone: ToneName | null }[]
|
|
23
|
-
/** By source name: what the herd puts before a row of "
|
|
25
|
+
/** By source name: what the herd puts before a row of "Your turn" that came from the source, its `label` or else its name. */
|
|
24
26
|
sourceLabels: Record<string, string>
|
|
25
27
|
/** The tone of each role in the herd. */
|
|
26
28
|
tones: JackherdConfig['plans']['tones']
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
/** The locale of the herd
|
|
31
|
+
/** The locale of the times the herd writes (the refresh error, the last fetch): English, with a 24-hour clock. */
|
|
30
32
|
export const CLOCK_LOCALE = 'en-GB'
|
|
31
33
|
|
|
32
34
|
/** The herd's slice of a validated config. */
|
|
@@ -40,6 +42,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
40
42
|
skillPrefix: config.skillPrefix,
|
|
41
43
|
adapter: config.terminal.adapter,
|
|
42
44
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
45
|
+
shimmer: config.plans.shimmer,
|
|
43
46
|
linkKinds: config.plans.linkKinds,
|
|
44
47
|
context: config.plans.context,
|
|
45
48
|
ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ESC, stripAnsi } from '../terminal/text'
|
|
2
|
+
|
|
3
|
+
/** A line of the frame that shimmers, by its row on the terminal (1-based) and its text with the marks in it. */
|
|
4
|
+
export type ShimmerRow = { row: number; line: string }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The marks around a text that shimmers: SGR 5 and 25 (blink on, off), which nothing else in the herd writes.
|
|
8
|
+
* `shimmerAt` turns them into the stripe of the moment, so no line reaches the terminal with them.
|
|
9
|
+
*/
|
|
10
|
+
const MARK = { open: `${ESC}[5m`, close: `${ESC}[25m` } as const
|
|
11
|
+
const MARKED = new RegExp(`${ESC}\\[5m([^${ESC}]*)${ESC}\\[25m`, 'g')
|
|
12
|
+
const LONE_MARK = new RegExp(`${ESC}\\[2?5m`, 'g')
|
|
13
|
+
const DEFAULT_FOREGROUND = `${ESC}[39m`
|
|
14
|
+
|
|
15
|
+
type Rgb = readonly [number, number, number]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The stripe is drawn `framesPerSecond` times a second; its middle glides `cellsPerSecond`, and it lifts the cells
|
|
19
|
+
* within `reachCells` of it in a soft bell from the teal `base` the whole text stands in to the light teal `peak`.
|
|
20
|
+
* After the stripe left the longest text on screen every row rests `pauseMs`, then all start again together.
|
|
21
|
+
*/
|
|
22
|
+
export const SHIMMER = {
|
|
23
|
+
framesPerSecond: 30,
|
|
24
|
+
cellsPerSecond: 20,
|
|
25
|
+
pauseMs: 600,
|
|
26
|
+
reachCells: 4.5,
|
|
27
|
+
base: [80, 180, 170] as Rgb,
|
|
28
|
+
peak: [175, 240, 232] as Rgb,
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
/** The width of the bell: at `reachCells` from its middle a cell keeps 5 % of the lift. */
|
|
32
|
+
const BELL_WIDTH = SHIMMER.reachCells / Math.sqrt(3)
|
|
33
|
+
/** The six levels of each channel in the colour cube of the 256 colours, which starts at index 16. */
|
|
34
|
+
const CUBE_LEVELS: readonly number[] = [0, 95, 135, 175, 215, 255]
|
|
35
|
+
const CUBE_FIRST = 16
|
|
36
|
+
|
|
37
|
+
const nearestLevel = (value: number): number => {
|
|
38
|
+
const distances = CUBE_LEVELS.map((level) => Math.abs(level - value))
|
|
39
|
+
return distances.indexOf(Math.min(...distances))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The colour of the 256 nearest to `rgb`, channel by channel in the colour cube. */
|
|
43
|
+
const cubeIndex = ([red, green, blue]: Rgb): number =>
|
|
44
|
+
CUBE_FIRST + 36 * nearestLevel(red) + 6 * nearestLevel(green) + nearestLevel(blue)
|
|
45
|
+
|
|
46
|
+
const colourCode = (rgb: Rgb, trueColour: boolean): string =>
|
|
47
|
+
trueColour ? `38;2;${rgb[0]};${rgb[1]};${rgb[2]}` : `38;5;${cubeIndex(rgb)}`
|
|
48
|
+
|
|
49
|
+
export const isTrueColour = (env: Record<string, string | undefined>): boolean =>
|
|
50
|
+
env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit'
|
|
51
|
+
|
|
52
|
+
const TRUE_COLOUR = isTrueColour(process.env)
|
|
53
|
+
|
|
54
|
+
/** `text` marked to shimmer, as plain text: styling of its own would stop the marks from pairing. */
|
|
55
|
+
export const shimmering = (text: string): string => `${MARK.open}${stripAnsi(text)}${MARK.close}`
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `text` as plain text in the teal the shimmer rests in, the default foreground after it: a running task while the
|
|
59
|
+
* herd does not shimmer (`plans.shimmer: false`, `NO_COLOR`, `--once`). Only the foreground, so a focus background stays.
|
|
60
|
+
*/
|
|
61
|
+
export const stillTeal = (text: string, trueColour = TRUE_COLOUR): string =>
|
|
62
|
+
`${ESC}[${colourCode(SHIMMER.base, trueColour)}m${stripAnsi(text)}${DEFAULT_FOREGROUND}`
|
|
63
|
+
|
|
64
|
+
export const hasShimmer = (line: string): boolean => line.includes(MARK.open)
|
|
65
|
+
|
|
66
|
+
/** The characters of the longest marked text in `lines`, 0 without one: it sets the pass of every row. */
|
|
67
|
+
export const longestShimmer = (lines: readonly string[]): number =>
|
|
68
|
+
Math.max(0, ...lines.flatMap((line) => [...line.matchAll(MARKED)].map((match) => [...(match[1] ?? '')].length)))
|
|
69
|
+
|
|
70
|
+
/** One pass of the stripe over a longest text of `longest` characters, in and out of it, and the rest after it. */
|
|
71
|
+
export const shimmerCycleMs = (longest: number): number =>
|
|
72
|
+
((longest + 2 * SHIMMER.reachCells) / SHIMMER.cellsPerSecond) * 1000 + SHIMMER.pauseMs
|
|
73
|
+
|
|
74
|
+
/** The middle of the stripe at `now` (epoch milliseconds) as a fraction of cells, negative before the text. */
|
|
75
|
+
const stripeMiddle = (now: number, longest: number): number =>
|
|
76
|
+
((now % shimmerCycleMs(longest)) * SHIMMER.cellsPerSecond) / 1000 - SHIMMER.reachCells
|
|
77
|
+
|
|
78
|
+
/** The foreground of a cell `distance` cells from the middle: the teal lifted along the bell towards the peak. */
|
|
79
|
+
const shadeCode = (distance: number, trueColour: boolean): string => {
|
|
80
|
+
const weight = Math.exp(-((distance / BELL_WIDTH) ** 2))
|
|
81
|
+
const channel = (index: 0 | 1 | 2) =>
|
|
82
|
+
Math.round(SHIMMER.base[index] + (SHIMMER.peak[index] - SHIMMER.base[index]) * weight)
|
|
83
|
+
return colourCode([channel(0), channel(1), channel(2)], trueColour)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `text` in teal, each character within reach of `middle` in the shade its distance gives, the default foreground
|
|
88
|
+
* after it; the characters out of reach are written in one run, so a long text costs no more than a short one.
|
|
89
|
+
*/
|
|
90
|
+
const striped = (text: string, middle: number, trueColour: boolean): string => {
|
|
91
|
+
const characters = [...text]
|
|
92
|
+
const base = colourCode(SHIMMER.base, trueColour)
|
|
93
|
+
const from = Math.max(Math.floor(middle - SHIMMER.reachCells), 0)
|
|
94
|
+
const to = Math.min(Math.ceil(middle + SHIMMER.reachCells), characters.length)
|
|
95
|
+
if (to <= from) return `${ESC}[${base}m${text}${DEFAULT_FOREGROUND}`
|
|
96
|
+
let stripe = ''
|
|
97
|
+
let current = base
|
|
98
|
+
for (let index = from; index < to; index++) {
|
|
99
|
+
const code = shadeCode(index + 0.5 - middle, trueColour)
|
|
100
|
+
if (code !== current) stripe += `${ESC}[${code}m`
|
|
101
|
+
current = code
|
|
102
|
+
stripe += characters[index]
|
|
103
|
+
}
|
|
104
|
+
const rest = characters.slice(to).join('')
|
|
105
|
+
const after = rest && current !== base ? `${ESC}[${base}m${rest}` : rest
|
|
106
|
+
return `${ESC}[${base}m${characters.slice(0, from).join('')}${stripe}${after}${DEFAULT_FOREGROUND}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `line` as drawn at `now`: each marked text with the stripe of the moment, the marks gone, a mark left alone by a cut
|
|
111
|
+
* too. `longest` is `longestShimmer` over the frame, so every row starts and rests together.
|
|
112
|
+
*/
|
|
113
|
+
export const shimmerAt = (line: string, now: number, longest: number, trueColour = TRUE_COLOUR): string =>
|
|
114
|
+
line
|
|
115
|
+
.replace(MARKED, (_marked, text: string) => striped(text, stripeMiddle(now, longest), trueColour))
|
|
116
|
+
.replace(LONE_MARK, '')
|
|
117
|
+
|
|
118
|
+
/** The lines of a frame that shimmer, by their row on the terminal; `lines` start at the top row. */
|
|
119
|
+
export const shimmerRows = (lines: readonly string[]): ShimmerRow[] =>
|
|
120
|
+
lines.flatMap((line, index) => (hasShimmer(line) ? [{ row: index + 1, line }] : []))
|
|
121
|
+
|
|
122
|
+
/** Whether running tasks shimmer: while the config's `plans.shimmer` allows it, not with `NO_COLOR` set to a value (no-color.org). */
|
|
123
|
+
export const shimmerEnabled = (configured: boolean, env: Record<string, string | undefined>): boolean =>
|
|
124
|
+
configured && !env.NO_COLOR
|
|
@@ -39,6 +39,22 @@ const recentSessionLogs = (project: string, notOlderThan: number): string[] => {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** One read of the signals: the recent logs, how one is read, and what ends the read (the cache cleaned, the records added). */
|
|
43
|
+
const signalRound = ({ projects, root, worktrees }: SignalSource, now: number, cache: StatCache<TaskSignal[]>) => {
|
|
44
|
+
const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
|
|
45
|
+
const round: SignalCache = { entries: cache, used: new Set<string>() }
|
|
46
|
+
return {
|
|
47
|
+
paths: [root, ...subdirectories(worktrees)].flatMap((directory) =>
|
|
48
|
+
recentSessionLogs(join(projects, projectDirectoryOf(directory)), notOlderThan),
|
|
49
|
+
),
|
|
50
|
+
read: (path: string) => readClaudeSignals(path, { notOlderThan, cache: round }),
|
|
51
|
+
finish: (claude: TaskSignal[]): TaskSignal[] => {
|
|
52
|
+
dropUnused(round.entries, round.used)
|
|
53
|
+
return [...claude, ...recordSignals(readTaskRecords(root))]
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
/**
|
|
43
59
|
* What agents report doing, as `TaskSignal`s: the tasks in progress in the recent Claude Code sessions of the main
|
|
44
60
|
* checkout (the coordinator and its subagents) and of every worktree, and the open `jh task` records. Which lane a
|
|
@@ -46,17 +62,29 @@ const recentSessionLogs = (project: string, notOlderThan: number): string[] => {
|
|
|
46
62
|
* log whose modification time and size did not change; logs this call did not look at are dropped from it.
|
|
47
63
|
*/
|
|
48
64
|
export const readAllSignals = (
|
|
49
|
-
|
|
65
|
+
source: SignalSource,
|
|
50
66
|
now: number,
|
|
51
67
|
cache: StatCache<TaskSignal[]> = new Map(),
|
|
52
68
|
): TaskSignal[] => {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
const round = signalRound(source, now, cache)
|
|
70
|
+
return round.finish(round.paths.flatMap(round.read))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `readAllSignals` with `pass` awaited before each log is parsed, so a gate (`createSliceGate`) can hand the event
|
|
75
|
+
* loop back between them; the result and the cache are the same.
|
|
76
|
+
*/
|
|
77
|
+
export const readAllSignalsSliced = async (
|
|
78
|
+
source: SignalSource,
|
|
79
|
+
now: number,
|
|
80
|
+
cache: StatCache<TaskSignal[]>,
|
|
81
|
+
pass: () => Promise<void>,
|
|
82
|
+
): Promise<TaskSignal[]> => {
|
|
83
|
+
const round = signalRound(source, now, cache)
|
|
84
|
+
const claude: TaskSignal[] = []
|
|
85
|
+
for (const path of round.paths) {
|
|
86
|
+
await pass()
|
|
87
|
+
claude.push(...round.read(path))
|
|
88
|
+
}
|
|
89
|
+
return round.finish(claude)
|
|
62
90
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Where the gate hands the event loop back and what it times the slices with; `setImmediate` and `performance.now`. */
|
|
2
|
+
export type SliceLoop = { schedule: (turn: () => void) => void; clock: () => number }
|
|
3
|
+
|
|
4
|
+
/** A gate that cuts long synchronous work into slices of the event loop. */
|
|
5
|
+
export type SliceGate = { pass: () => Promise<void> }
|
|
6
|
+
|
|
7
|
+
const SYSTEM_LOOP: SliceLoop = { schedule: (turn) => setImmediate(turn), clock: () => performance.now() }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A gate the steps of a read pass before each piece of synchronous work. While the current slice is shorter than
|
|
11
|
+
* `budgetMs` a step passes at once; after that the gate hands the loop back (keys, mouse and drawing come first) and
|
|
12
|
+
* lets the waiting steps through in the order they asked, one at a time, each after the one before it reached its
|
|
13
|
+
* next `await`, as long as the new slice has time. Steps that run side by side (`Promise.all` over the lanes) thus
|
|
14
|
+
* take turns instead of running their synchronous parts back to back in one block.
|
|
15
|
+
*/
|
|
16
|
+
export const createSliceGate = (budgetMs: number, loop: SliceLoop = SYSTEM_LOOP): SliceGate => {
|
|
17
|
+
const waiting: (() => void)[] = []
|
|
18
|
+
let sliceStart = loop.clock()
|
|
19
|
+
let scheduled = false
|
|
20
|
+
const hasTime = () => loop.clock() - sliceStart < budgetMs
|
|
21
|
+
|
|
22
|
+
const scheduleTurn = () => {
|
|
23
|
+
if (scheduled) return
|
|
24
|
+
scheduled = true
|
|
25
|
+
loop.schedule(() => {
|
|
26
|
+
scheduled = false
|
|
27
|
+
sliceStart = loop.clock()
|
|
28
|
+
releaseNext()
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const releaseNext = () => {
|
|
33
|
+
const next = waiting.shift()
|
|
34
|
+
if (!next) return
|
|
35
|
+
next()
|
|
36
|
+
void Promise.resolve().then(() => {
|
|
37
|
+
if (waiting.length === 0) return
|
|
38
|
+
if (hasTime()) releaseNext()
|
|
39
|
+
else scheduleTurn()
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pass = (): Promise<void> => {
|
|
44
|
+
if (waiting.length === 0 && !scheduled && hasTime()) return Promise.resolve()
|
|
45
|
+
const passed = new Promise<void>((resolve) => waiting.push(resolve))
|
|
46
|
+
scheduleTurn()
|
|
47
|
+
return passed
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { pass }
|
|
51
|
+
}
|