jackherd 1.11.2 → 1.12.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/CHANGELOG.md +21 -0
- package/README.md +69 -51
- 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/next-rows.ts
CHANGED
|
@@ -1,44 +1,12 @@
|
|
|
1
1
|
import { sectionLines } from '../plan-format/markdown'
|
|
2
2
|
import { NEXT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
-
import { shortTitle } from './task-name'
|
|
4
|
-
import type { BlockItem } from './waiting-click'
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
export type NextRow = { text: string }
|
|
8
|
-
|
|
9
|
-
/** The next block: the section's lines without the ones the for-you block already shows. */
|
|
10
|
-
export type NextBlock = { rows: NextRow[] }
|
|
11
|
-
|
|
12
|
-
const MAX_NEXT_ROWS = 3
|
|
4
|
+
const MAX_NEXT_LINES = 3
|
|
13
5
|
|
|
14
6
|
const LIST_LINE = /^(?:\d+[.)]|[-*])\s+(.+)$/
|
|
15
7
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* The next section of the board, up to three list lines in the order written; the parser cuts after three, as the board
|
|
20
|
-
* says. A line equal to one of `shown` (the texts of the for-you block) is left out: it stands above already.
|
|
21
|
-
*/
|
|
22
|
-
export const nextBlock = (board: string, language: PlanLanguage, shown: readonly string[]): NextBlock => ({
|
|
23
|
-
rows: sectionLines(board, NEXT_VOCABULARY[language].heading)
|
|
8
|
+
/** The board's next section, up to three list lines in the order written; the parser cuts after three, as the board says. */
|
|
9
|
+
export const nextLines = (board: string, language: PlanLanguage): string[] =>
|
|
10
|
+
sectionLines(board, NEXT_VOCABULARY[language].heading)
|
|
24
11
|
.flatMap((line) => LIST_LINE.exec(line.trim())?.[1] ?? [])
|
|
25
|
-
.slice(0,
|
|
26
|
-
.filter((text) => !shown.some((known) => sameText(known, text)))
|
|
27
|
-
.map((text) => ({ text })),
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
const nextKey = ({ text }: NextRow): string => `next>${text}`
|
|
31
|
-
|
|
32
|
-
/** The rows as the block draws them, numbered; a click on one only expands it. */
|
|
33
|
-
export const nextItems = (rows: readonly NextRow[]): BlockItem[] =>
|
|
34
|
-
rows.map((row, index) => ({
|
|
35
|
-
key: nextKey(row),
|
|
36
|
-
mark: String(index + 1),
|
|
37
|
-
text: row.text,
|
|
38
|
-
title: shortTitle(row.text),
|
|
39
|
-
url: null,
|
|
40
|
-
ask: null,
|
|
41
|
-
}))
|
|
42
|
-
|
|
43
|
-
/** The keys of all rows: what the fold of the block remembers. */
|
|
44
|
-
export const nextKeys = (rows: readonly NextRow[]): string[] => rows.map(nextKey)
|
|
12
|
+
.slice(0, MAX_NEXT_LINES)
|
|
@@ -50,9 +50,14 @@ export const newerPhases = (base: readonly PlanPhase[], copy: readonly PlanPhase
|
|
|
50
50
|
return [...inBaseOrder, ...copy.filter((phase) => !baseIds.has(phase.id))]
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
/**
|
|
54
|
+
* A phase read from the worktree is touched when its ticks differ from the base's, or the base lacks it; a touched
|
|
55
|
+
* one is merged as well when the base already carries a tick of it.
|
|
56
|
+
*/
|
|
57
|
+
const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase => {
|
|
58
|
+
if (basePhase && sameTicks(phase, basePhase)) return phase
|
|
59
|
+
return basePhase && tickCount(basePhase) > 0 ? { ...phase, touched: true, merged: true } : { ...phase, touched: true }
|
|
60
|
+
}
|
|
56
61
|
|
|
57
62
|
/**
|
|
58
63
|
* The phases of a plan as the lane sees them: the base ref's phases in their order, each replaced by the
|
package/src/plans/plan-chips.ts
CHANGED
|
@@ -51,10 +51,13 @@ export const planAnchorOn = (pagePath: string | null, address: string): string |
|
|
|
51
51
|
/**
|
|
52
52
|
* What decides whether the written page is out of date: the sprint text, the plan texts and the planned phases.
|
|
53
53
|
* The status of the lanes is left out on purpose, it changes every few seconds and building the page costs
|
|
54
|
-
* seconds; a page older than that says so in its header, and the key `s` writes a fresh one.
|
|
54
|
+
* seconds; a page older than that says so in its header, and the key `s` writes a fresh one. The texts, megabytes on
|
|
55
|
+
* a busy board and asked every refresh, are hashed as they are, not escaped into JSON first.
|
|
55
56
|
*/
|
|
56
|
-
export const pageKey = ({ documents, planned }: Snapshot): string =>
|
|
57
|
-
|
|
57
|
+
export const pageKey = ({ documents: { sprint, plans, ...rest }, planned }: Snapshot): string => {
|
|
58
|
+
const planHashes = Object.entries(plans).map(([href, text]) => [href, Bun.hash(text).toString(36)])
|
|
59
|
+
return Bun.hash(JSON.stringify([Bun.hash(sprint).toString(36), planHashes, rest, planned])).toString(36)
|
|
60
|
+
}
|
|
58
61
|
|
|
59
62
|
/** The sprint page last written, with the key of the plans it was built from. */
|
|
60
63
|
export type KnownPage = WrittenPage & { key: string }
|
package/src/plans/plan-heads.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type PlanPullRequest = Pick<PullRequest, 'number' | 'branch' | 'files'>
|
|
|
8
8
|
export type PlanHead = { number: number; files: readonly string[]; read: BaseReader }
|
|
9
9
|
|
|
10
10
|
/** Where the herd keeps the fetched heads of open pull requests: its own namespace, apart from branches and remotes. */
|
|
11
|
-
const PULL_HEAD_NAMESPACE = 'refs/jackherd/pr'
|
|
11
|
+
export const PULL_HEAD_NAMESPACE = 'refs/jackherd/pr'
|
|
12
12
|
|
|
13
13
|
export const pullHeadRef = (number: number): string => `${PULL_HEAD_NAMESPACE}/${number}`
|
|
14
14
|
|
package/src/plans/planned.ts
CHANGED
|
@@ -37,6 +37,8 @@ export type PlannedInput = {
|
|
|
37
37
|
lanes: readonly PlannedLane[]
|
|
38
38
|
sprintDirectory: string
|
|
39
39
|
language: PlanLanguage
|
|
40
|
+
/** The phases of a plan's text, by its file; defaults to parsing it, a caller with a plan cache passes its own. */
|
|
41
|
+
parse?: (markdown: string, path: string) => readonly PlanPhase[]
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** A plan's file, its text and its phases, as one plan row reads them. */
|
|
@@ -75,11 +77,12 @@ const plannedPlanOf = ({ path, markdown, phases: all }: PlanRead, language: Plan
|
|
|
75
77
|
export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
|
|
76
78
|
const followed = followedPaths(input)
|
|
77
79
|
const seen = new Set<string>()
|
|
80
|
+
const parse = input.parse ?? ((markdown: string) => parsePlanPhases(markdown, input.language))
|
|
78
81
|
return Object.entries(input.plans).flatMap(([href, markdown]) => {
|
|
79
82
|
const path = resolvePlanPath(input.sprintDirectory, href)
|
|
80
83
|
if (seen.has(path) || followed.has(path)) return []
|
|
81
84
|
seen.add(path)
|
|
82
|
-
return plannedPlanOf({ path, markdown, phases:
|
|
85
|
+
return plannedPlanOf({ path, markdown, phases: parse(markdown, path) }, input.language)
|
|
83
86
|
})
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Runs tasks with at most a fixed number in flight; the others wait in the order they asked. */
|
|
2
|
+
export type ProcessSlots = { run: <T>(task: () => Promise<T>) => Promise<T> }
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Slots for child processes. The event loop handles every process that ended in one turn, so dozens started side by
|
|
6
|
+
* side end as one long block; with `limit` in flight, their ends arrive spread over several turns.
|
|
7
|
+
*/
|
|
8
|
+
export const createProcessSlots = (limit: number): ProcessSlots => {
|
|
9
|
+
const waiting: (() => void)[] = []
|
|
10
|
+
let inFlight = 0
|
|
11
|
+
|
|
12
|
+
const acquire = (): Promise<void> => {
|
|
13
|
+
if (inFlight < limit) {
|
|
14
|
+
inFlight += 1
|
|
15
|
+
return Promise.resolve()
|
|
16
|
+
}
|
|
17
|
+
return new Promise<void>((resolve) => waiting.push(resolve))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const release = () => {
|
|
21
|
+
const next = waiting.shift()
|
|
22
|
+
if (next) next()
|
|
23
|
+
else inFlight -= 1
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const run = async <T>(task: () => Promise<T>): Promise<T> => {
|
|
27
|
+
await acquire()
|
|
28
|
+
try {
|
|
29
|
+
return await task()
|
|
30
|
+
} finally {
|
|
31
|
+
release()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { run }
|
|
36
|
+
}
|
|
@@ -188,8 +188,16 @@ export const pullRequestsByBranch = (pullRequests: readonly PullRequest[]): Map<
|
|
|
188
188
|
new Map<string, PullRequest[]>(),
|
|
189
189
|
)
|
|
190
190
|
|
|
191
|
+
const PULL_REQUEST_KEY_PREFIX = 'pr:'
|
|
192
|
+
|
|
191
193
|
/** The selection key of a pull request row; two parts, so it never equals a three-part phase key. */
|
|
192
|
-
export const pullRequestKey = (number: number): string =>
|
|
194
|
+
export const pullRequestKey = (number: number): string => `${PULL_REQUEST_KEY_PREFIX}${number}`
|
|
195
|
+
|
|
196
|
+
/** The number a pull request key names, null for any other key. */
|
|
197
|
+
export const pullRequestNumberOfKey = (key: string): number | null => {
|
|
198
|
+
const digits = key.startsWith(PULL_REQUEST_KEY_PREFIX) ? key.slice(PULL_REQUEST_KEY_PREFIX.length) : ''
|
|
199
|
+
return /^\d+$/.test(digits) ? Number(digits) : null
|
|
200
|
+
}
|
|
193
201
|
|
|
194
202
|
/**
|
|
195
203
|
* The key of a failed test's line under a pull request, by its place in the list: a click unfolds a cut name and folds
|
package/src/plans/read-cache.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import type { StatCache } from '../shared/stat-cache'
|
|
2
2
|
import type { TaskSignal } from '../task-signals/types'
|
|
3
|
+
import { type BoardRowsReader, boardRowsReader } from './board-rows'
|
|
3
4
|
import type { PlanCache } from './plan-cache'
|
|
5
|
+
import type { KeptStatus } from './status-pace'
|
|
4
6
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* What the herd keeps between its reads, so session logs, plans and a board that did not change are not parsed
|
|
9
|
+
* again, and a worktree where nothing moved is not asked `git status` again (`pacedStatus`).
|
|
10
|
+
*/
|
|
11
|
+
export type ReadCache = {
|
|
12
|
+
signals: StatCache<TaskSignal[]>
|
|
13
|
+
plans: PlanCache
|
|
14
|
+
boardRows: BoardRowsReader
|
|
15
|
+
statuses: Map<string, KeptStatus>
|
|
16
|
+
}
|
|
7
17
|
|
|
8
|
-
export const newReadCache = (): ReadCache => ({
|
|
18
|
+
export const newReadCache = (): ReadCache => ({
|
|
19
|
+
signals: new Map(),
|
|
20
|
+
plans: new Map(),
|
|
21
|
+
boardRows: boardRowsReader(),
|
|
22
|
+
statuses: new Map(),
|
|
23
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How often the live view reads everything again: at `baseMs` while the screen changes, slower while it does not, up
|
|
3
|
+
* to `maxMs`. A watched plan file still refreshes at once; the pace only decides how soon an unwatched change (a session
|
|
4
|
+
* log, an agent's status, git) shows after a quiet spell.
|
|
5
|
+
*/
|
|
6
|
+
export const REFRESH_PACE = { baseMs: 5_000, maxMs: 20_000 } as const
|
|
7
|
+
|
|
8
|
+
/** The wait before the next refresh after `quietRounds` refreshes in a row wrote nothing: doubled per quiet round, up to `maxMs`. */
|
|
9
|
+
export const refreshDelay = (quietRounds: number): number =>
|
|
10
|
+
Math.min(REFRESH_PACE.baseMs * 2 ** quietRounds, REFRESH_PACE.maxMs)
|
|
11
|
+
|
|
12
|
+
/** The quiet rounds after a refresh: one more when it wrote nothing, none when it changed the screen. */
|
|
13
|
+
export const quietRoundsAfter = (quietRounds: number, changed: boolean): number => (changed ? 0 : quietRounds + 1)
|
package/src/plans/row-focus.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TONE_ROLE } from '../config/tones'
|
|
2
|
+
import { ESC, FAINT_OFF, FAINT_ON, RESET, stripAnsi, visibleLength } from '../terminal/text'
|
|
2
3
|
import { textWidth } from '../terminal/width'
|
|
3
4
|
import { isChipKey } from './link-chips'
|
|
4
5
|
import type { ClickTarget, MouseEvent, Selection } from './selection'
|
|
6
|
+
import { CHEVRON } from './symbols'
|
|
7
|
+
import { paintOfRole } from './tones'
|
|
5
8
|
import type { BlockHit, BlockRow } from './waiting-click'
|
|
6
9
|
|
|
7
10
|
/** A clickable row on screen: its key, the screen line (0-based, header lines first) it starts on and how many lines it takes. */
|
|
@@ -12,18 +15,34 @@ export const FOCUS_ON = `${ESC}[48;5;238m`
|
|
|
12
15
|
|
|
13
16
|
const BACKGROUND_LOST = new RegExp(`${ESC}\\[(?:0|49)?m`, 'g')
|
|
14
17
|
const LEADING_STYLES = new RegExp(`^((?:${ESC}\\[[0-9;]*m)*)(.)`, 'u')
|
|
15
|
-
/** The cells a row's lead may give up for the mark: a blank
|
|
16
|
-
const MARKABLE_LEADS = new Set([' ', '▌'
|
|
18
|
+
/** The cells a row's lead may give up for the mark: a blank or the bar of a block; a chevron or a tree branch says more. */
|
|
19
|
+
const MARKABLE_LEADS = new Set([' ', '▌'])
|
|
20
|
+
/** A chevron that leads a row, behind nothing but styles and blanks. */
|
|
21
|
+
const LEADING_CHEVRON = new RegExp(`^((?:${ESC}\\[[0-9;]*m| )*)([${CHEVRON.closed}${CHEVRON.open}])`, 'u')
|
|
22
|
+
|
|
23
|
+
/** Whether the styles before a point leave the dim on: it was set and neither unset nor reset since. */
|
|
24
|
+
const dimIsOn = (styles: string): boolean =>
|
|
25
|
+
styles.lastIndexOf(FAINT_ON) > Math.max(styles.lastIndexOf(FAINT_OFF), styles.lastIndexOf(RESET))
|
|
26
|
+
|
|
27
|
+
/** The chevron of a row in the tone of what runs, the dim around it lifted for it alone. */
|
|
28
|
+
const paintedChevron = (styles: string, chevron: string): string => {
|
|
29
|
+
const dimmed = dimIsOn(styles)
|
|
30
|
+
return `${styles}${dimmed ? FAINT_OFF : ''}${paintOfRole(TONE_ROLE.running)(chevron)}${dimmed ? FAINT_ON : ''}`
|
|
31
|
+
}
|
|
17
32
|
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
/** The row's chevron painted, which says the row opens; else the `❯` on the blank or bar that leads it. */
|
|
34
|
+
const markedLead = (line: string): string => {
|
|
35
|
+
if (LEADING_CHEVRON.test(line))
|
|
36
|
+
return line.replace(LEADING_CHEVRON, (_whole, styles: string, chevron: string) => paintedChevron(styles, chevron))
|
|
37
|
+
return line.replace(LEADING_STYLES, (whole, styles: string, lead: string) =>
|
|
20
38
|
MARKABLE_LEADS.has(lead) ? `${styles}${FOCUS_MARK}` : whole,
|
|
21
39
|
)
|
|
40
|
+
}
|
|
22
41
|
|
|
23
42
|
/**
|
|
24
43
|
* A row under the mouse or the selection: a light background across `width` cells, and, on the first line of the row
|
|
25
|
-
* (`marked`), the `❯` in place of the bar
|
|
26
|
-
* every reset inside the line, so styled text and buttons keep it.
|
|
44
|
+
* (`marked`), its leading chevron in the tone of what runs, or the `❯` in place of the bar or blank that leads it. The
|
|
45
|
+
* background is laid again after every reset inside the line, so styled text and buttons keep it.
|
|
27
46
|
*/
|
|
28
47
|
export const focusLine = (line: string, width: number, marked: boolean): string => {
|
|
29
48
|
const shown = marked ? markedLead(line) : line
|
|
@@ -38,6 +57,14 @@ export const focusHitAt = (hits: readonly FocusHit[], line: number): FocusHit |
|
|
|
38
57
|
/** The screen a motion report is looked up on: header lines, the body's rows and how far it is scrolled. */
|
|
39
58
|
export type HoverFrame = { headerRows: number; bodyRows: number; top: number }
|
|
40
59
|
|
|
60
|
+
/** A drawn screen as the mouse sees it: its lines, the rows on it and how the body is scrolled. */
|
|
61
|
+
export type HoverScreen = {
|
|
62
|
+
header: readonly string[]
|
|
63
|
+
bodyLines: readonly string[]
|
|
64
|
+
focusHits: readonly FocusHit[]
|
|
65
|
+
frame: HoverFrame
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
/** The screen line (0-based, header first, the body unscrolled) under a motion report; null in the footer. */
|
|
42
69
|
export const lineIndexAt = ({ row }: MouseEvent, { headerRows, bodyRows, top }: HoverFrame): number | null => {
|
|
43
70
|
const line = row - 1
|
|
@@ -98,14 +125,53 @@ export const boldBracketAt = (line: string, from: number): string => {
|
|
|
98
125
|
/** The key of the hit of a line that holds links but is no row of its own: it is marked by the mouse only. */
|
|
99
126
|
export const LINKS_KEY_PREFIX = 'links@'
|
|
100
127
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
const isLinksKey = (key: string | null | undefined): boolean => key?.startsWith(LINKS_KEY_PREFIX) === true
|
|
129
|
+
|
|
130
|
+
/** Whether the mouse moving onto `key` takes the selection: a row does, a line of links (no row of its own) and none do not. */
|
|
131
|
+
const takesSelection = (key: string | null): key is string => key !== null && !isLinksKey(key)
|
|
132
|
+
|
|
133
|
+
/** Whether the selected row is the one the mouse chose, not one the arrows moved to. */
|
|
134
|
+
const isMouseChosen = ({ selected, hovered }: Selection): boolean => (hovered ?? null) !== null && hovered === selected
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The selection with the row under the mouse and the button in it set; the row also becomes the selected one, so
|
|
138
|
+
* the mouse and the arrows share one focus. When the mouse leaves for a line without a row (a blank line, a head, a
|
|
139
|
+
* text, a line of links), the row it chose is no longer selected; a row the arrows selected stays. The selection
|
|
140
|
+
* itself while nothing changes.
|
|
141
|
+
*/
|
|
142
|
+
export const withHovered = (selection: Selection, key: string | null, from: number | null = null): Selection => {
|
|
143
|
+
const kept = isMouseChosen(selection) ? null : selection.selected
|
|
144
|
+
const selected = takesSelection(key) ? key : kept
|
|
145
|
+
const same =
|
|
146
|
+
(selection.hovered ?? null) === key && (selection.hoveredFrom ?? null) === from && selection.selected === selected
|
|
147
|
+
return same ? selection : { ...selection, selected, hovered: key, hoveredFrom: from }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The selection without the row under the mouse: a key press hands the focus back to the arrows. */
|
|
151
|
+
export const withoutHover = (selection: Selection): Selection =>
|
|
152
|
+
(selection.hovered ?? null) === null && (selection.hoveredFrom ?? null) === null
|
|
104
153
|
? selection
|
|
105
|
-
: { ...selection, hovered:
|
|
154
|
+
: { ...selection, hovered: null, hoveredFrom: null }
|
|
155
|
+
|
|
156
|
+
/** The selection with the row and the square-bracket button under the mouse, as `withHovered` sets them. */
|
|
157
|
+
export const hoveredSelection = (selection: Selection, screen: HoverScreen, mouse: MouseEvent): Selection => {
|
|
158
|
+
const key = hoveredKeyAt(screen.focusHits, mouse, screen.frame)
|
|
159
|
+
const line = lineIndexAt(mouse, screen.frame)
|
|
160
|
+
const text = line === null ? '' : (screen.header[line] ?? screen.bodyLines[line - screen.frame.headerRows] ?? '')
|
|
161
|
+
return withHovered(selection, key, key === null ? null : bracketAt(stripAnsi(text), mouse.column - 1))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The selection after a new frame under a mouse that did not move (`mouse` is its last report, null before the first):
|
|
166
|
+
* a layout that moved another row under it gives that row the focus. Only while the mouse has the focus; after a key
|
|
167
|
+
* press the arrows keep it. The selection itself when nothing changes.
|
|
168
|
+
*/
|
|
169
|
+
export const rehovered = (selection: Selection, screen: HoverScreen, mouse: MouseEvent | null): Selection =>
|
|
170
|
+
mouse === null || (selection.hovered ?? null) === null ? selection : hoveredSelection(selection, screen, mouse)
|
|
106
171
|
|
|
107
|
-
/** Whether a row
|
|
108
|
-
export const isFocused = ({ selected, hovered }: Selection, key: string): boolean =>
|
|
172
|
+
/** Whether a row has the focus: the selected one, unless the mouse is on a line of links, which then has it alone. */
|
|
173
|
+
export const isFocused = ({ selected, hovered }: Selection, key: string): boolean =>
|
|
174
|
+
key === selected && !isLinksKey(hovered)
|
|
109
175
|
|
|
110
176
|
/** Whether the buttons of a row are all bold: it is the row the arrows selected, and the mouse is not on it. */
|
|
111
177
|
export const buttonsAllBold = ({ selected, hovered }: Selection, key: string): boolean =>
|
|
@@ -115,14 +181,19 @@ export const buttonsAllBold = ({ selected, hovered }: Selection, key: string): b
|
|
|
115
181
|
export const blockRowByKey = (blocks: readonly (BlockHit | null)[], key: string): BlockRow | null =>
|
|
116
182
|
blocks.flatMap((block) => block?.rows ?? []).find((row) => row.key === key) ?? null
|
|
117
183
|
|
|
118
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* The head of a block on top of the herd, when it can open, then its rows from the screen line of its first row; no
|
|
186
|
+
* rows while the block is folded, nothing while it is not drawn.
|
|
187
|
+
*/
|
|
119
188
|
export const focusHitsOfBlock = (block: BlockHit | null): FocusHit[] => {
|
|
120
189
|
let line = block?.firstLine ?? 0
|
|
121
|
-
|
|
190
|
+
const head = block?.headKey ? [{ key: block.headKey, line: line - 1, lines: 1 }] : []
|
|
191
|
+
const rows = (block?.rows ?? []).map((row) => {
|
|
122
192
|
const hit = { key: row.key, line, lines: row.lines.length }
|
|
123
193
|
line += row.lines.length
|
|
124
194
|
return hit
|
|
125
195
|
})
|
|
196
|
+
return [...head, ...rows]
|
|
126
197
|
}
|
|
127
198
|
|
|
128
199
|
/**
|