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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LaneStatus } from './layout'
|
|
2
|
+
|
|
3
|
+
/** What `git status` tells the herd about a worktree. */
|
|
4
|
+
export type BranchStatus = Pick<LaneStatus, 'branch' | 'dirty'>
|
|
5
|
+
|
|
6
|
+
/** The last `git status` of a worktree, with the stamp of the worktree it was asked at and when (epoch milliseconds). */
|
|
7
|
+
export type KeptStatus = { stamp: string; at: number; status: BranchStatus }
|
|
8
|
+
|
|
9
|
+
/** Where and when a status is asked: the worktree, its stamp (`worktreeStamp`) and the time. */
|
|
10
|
+
export type StatusAsk = { worktree: string; stamp: string; now: number }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How long a kept `git status` holds while its worktree's stamp stays the same: a change made by hand, which moves
|
|
14
|
+
* neither the index nor HEAD nor an agent's log, shows within this time.
|
|
15
|
+
*/
|
|
16
|
+
export const STATUS_PACE = { maxAgeMs: 60_000 } as const
|
|
17
|
+
|
|
18
|
+
/** How much of an agent's last log line the stamp keeps: its end carries the entry's id and time. */
|
|
19
|
+
const LOG_MARK_CHARS = 200
|
|
20
|
+
|
|
21
|
+
const isFresh = (kept: KeptStatus | undefined, { stamp, now }: StatusAsk): kept is KeptStatus =>
|
|
22
|
+
kept !== undefined && kept.stamp === stamp && now - kept.at < STATUS_PACE.maxAgeMs
|
|
23
|
+
|
|
24
|
+
/** What the agent's log (its last lines, as the herd read them) says of its latest entry; empty without a log. */
|
|
25
|
+
export const logMark = (lines: readonly string[] | null): string => {
|
|
26
|
+
if (!lines) return ''
|
|
27
|
+
const last = lines.at(-1) ?? ''
|
|
28
|
+
return `${lines.length}:${last.length}:${last.slice(-LOG_MARK_CHARS)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The status of `ask.worktree`: the kept one while its stamp is unchanged and younger than `STATUS_PACE.maxAgeMs`,
|
|
33
|
+
* else `read` asked again and kept in `kept` with the stamp and time.
|
|
34
|
+
*/
|
|
35
|
+
export const pacedStatus = async (
|
|
36
|
+
kept: Map<string, KeptStatus>,
|
|
37
|
+
ask: StatusAsk,
|
|
38
|
+
read: () => Promise<BranchStatus>,
|
|
39
|
+
): Promise<BranchStatus> => {
|
|
40
|
+
const previous = kept.get(ask.worktree)
|
|
41
|
+
if (isFresh(previous, ask)) return previous.status
|
|
42
|
+
const status = await read()
|
|
43
|
+
kept.set(ask.worktree, { stamp: ask.stamp, at: ask.now, status })
|
|
44
|
+
return status
|
|
45
|
+
}
|
|
@@ -29,7 +29,7 @@ const startsWithName = (description: string, name: string): boolean =>
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* The open task each subagent works on, from the short name its description starts with (`/implement` Step 0b, case
|
|
32
|
-
* ignored): it gets its
|
|
32
|
+
* ignored): it gets its `●` like a reported task, one per task however many subagents share it. A ticked task and a
|
|
33
33
|
* review gate item take none; a subagent whose description starts with no name stays where it was.
|
|
34
34
|
*/
|
|
35
35
|
export const subagentTaskPlacements = (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { defaultProjectsDirectory,
|
|
3
|
+
import { defaultProjectsDirectory, followTailLines, projectDirectoryOf } from '../context/session-log'
|
|
4
4
|
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
5
5
|
import { DEFAULT_SKILL_PREFIX } from '../sync/init'
|
|
6
6
|
import { readApprovalRequests } from '../watch/approvals'
|
|
@@ -48,10 +48,24 @@ const directoriesOf = (path: string): string[] => {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/** How many directories keep their listing: one `subagents` directory per session, a few hundred in a busy project. */
|
|
52
|
+
const LISTED_DIRECTORIES = 2048
|
|
53
|
+
|
|
54
|
+
const listings = new Map<string, { modifiedAt: number; names: string[] }>()
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The entry names of `path`, listed again only when the directory changed since (a file added or removed moves its
|
|
58
|
+
* modification time, a write into a file does not); none when it is missing or cannot be read.
|
|
59
|
+
*/
|
|
52
60
|
const namesIn = (path: string): string[] => {
|
|
53
61
|
try {
|
|
54
|
-
|
|
62
|
+
const modifiedAt = statSync(path).mtimeMs
|
|
63
|
+
const kept = listings.get(path)
|
|
64
|
+
if (kept?.modifiedAt === modifiedAt) return kept.names
|
|
65
|
+
const names = readdirSync(path)
|
|
66
|
+
if (listings.size >= LISTED_DIRECTORIES) listings.clear()
|
|
67
|
+
listings.set(path, { modifiedAt, names })
|
|
68
|
+
return names
|
|
55
69
|
} catch {
|
|
56
70
|
return []
|
|
57
71
|
}
|
|
@@ -63,22 +77,23 @@ const logOf = (directory: string, name: string, id: string, now: number): Subage
|
|
|
63
77
|
const modifiedAt = statSync(logPath).mtimeMs
|
|
64
78
|
if (now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE) return []
|
|
65
79
|
const meta: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
|
|
66
|
-
return [{ id, meta, modifiedAt, tail:
|
|
80
|
+
return [{ id, meta, modifiedAt, tail: followTailLines(logPath, TAIL_BYTES) }]
|
|
67
81
|
} catch {
|
|
68
82
|
return []
|
|
69
83
|
}
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
/** The subagent logs of
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
return
|
|
77
|
-
const id = META_FILE.exec(name)?.[1]
|
|
78
|
-
return id ? logOf(directory, name, id, now) : []
|
|
79
|
-
})
|
|
86
|
+
/** The subagent logs of one session's `subagents` directory. */
|
|
87
|
+
const logsInSession = (directory: string, now: number): SubagentLog[] =>
|
|
88
|
+
namesIn(directory).flatMap((name) => {
|
|
89
|
+
const id = META_FILE.exec(name)?.[1]
|
|
90
|
+
return id ? logOf(directory, name, id, now) : []
|
|
80
91
|
})
|
|
81
92
|
|
|
93
|
+
/** The `subagents` directory of every session in one project directory. */
|
|
94
|
+
const sessionDirectoriesOf = (project: string): string[] =>
|
|
95
|
+
directoriesOf(project).map((session) => join(project, session, 'subagents'))
|
|
96
|
+
|
|
82
97
|
/**
|
|
83
98
|
* The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
|
|
84
99
|
* `worktreeDirectory` is given, in a worktree below it: a lane's own subagents, its reviews among them, live there.
|
|
@@ -89,6 +104,10 @@ const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?
|
|
|
89
104
|
return directoriesOf(projects).filter((name) => name === main || (lanes !== null && name.startsWith(lanes)))
|
|
90
105
|
}
|
|
91
106
|
|
|
107
|
+
/** The `subagents` directory of every session of the main checkout and its worktrees. */
|
|
108
|
+
const allSessionDirectories = (projects: string, root: string, worktreeDirectory?: string): string[] =>
|
|
109
|
+
projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionDirectoriesOf(join(projects, name)))
|
|
110
|
+
|
|
92
111
|
/**
|
|
93
112
|
* The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
|
|
94
113
|
* from Claude Code's `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read
|
|
@@ -100,7 +119,7 @@ export const readSubagentLogs = (
|
|
|
100
119
|
now: number,
|
|
101
120
|
worktreeDirectory?: string,
|
|
102
121
|
): SubagentLog[] =>
|
|
103
|
-
|
|
122
|
+
allSessionDirectories(projects, root, worktreeDirectory).flatMap((directory) => logsInSession(directory, now))
|
|
104
123
|
|
|
105
124
|
/** The names the gate holds an open request under, as the watcher reads them. */
|
|
106
125
|
const heldNames = (root: string, now: number): Set<string> =>
|
|
@@ -110,22 +129,40 @@ const heldNames = (root: string, now: number): Set<string> =>
|
|
|
110
129
|
.map((request) => request.lane),
|
|
111
130
|
)
|
|
112
131
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
{ projects, root, approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
|
|
119
|
-
now: number = Date.now(),
|
|
132
|
+
/** The running subagents among `logs`, the finished and the stale left out. */
|
|
133
|
+
const runningOf = (
|
|
134
|
+
logs: readonly SubagentLog[],
|
|
135
|
+
{ approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
|
|
136
|
+
now: number,
|
|
120
137
|
): RunningSubagent[] =>
|
|
121
138
|
runningSubagents(
|
|
122
|
-
|
|
139
|
+
logs,
|
|
123
140
|
heldNames(approvalsRoot, now),
|
|
124
141
|
now,
|
|
125
142
|
worktreeDirectory,
|
|
126
143
|
skillPrefix === undefined ? [DEFAULT_SKILL_PREFIX] : [DEFAULT_SKILL_PREFIX, skillPrefix],
|
|
127
144
|
)
|
|
128
145
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
146
|
+
/**
|
|
147
|
+
* The subagents at work in the sessions of the main checkout and its worktrees: their logs read, the finished and the
|
|
148
|
+
* stale left out. The one chain the herd (`readSubagentActivitySliced`) and the watcher's heartbeat share.
|
|
149
|
+
*/
|
|
150
|
+
export const readRunningSubagents = (source: SubagentSource, now: number = Date.now()): RunningSubagent[] =>
|
|
151
|
+
runningOf(readSubagentLogs(source.projects, source.root, now, source.worktreeDirectory), source, now)
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The state of every worktree a subagent of the main checkout's sessions works in, with `pass` awaited before each
|
|
155
|
+
* session's subagents are read, so a gate (`createSliceGate`) can hand the event loop back between them.
|
|
156
|
+
*/
|
|
157
|
+
export const readSubagentActivitySliced = async (
|
|
158
|
+
source: SubagentSource,
|
|
159
|
+
pass: () => Promise<void>,
|
|
160
|
+
now: number = Date.now(),
|
|
161
|
+
): Promise<Map<string, SubagentActivity>> => {
|
|
162
|
+
const logs: SubagentLog[] = []
|
|
163
|
+
for (const directory of allSessionDirectories(source.projects, source.root, source.worktreeDirectory)) {
|
|
164
|
+
await pass()
|
|
165
|
+
logs.push(...logsInSession(directory, now))
|
|
166
|
+
}
|
|
167
|
+
return subagentsByLane(runningOf(logs, source, now), source.worktreeDirectory)
|
|
168
|
+
}
|
package/src/plans/subagents.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { keptLogEntry } from '../context/session-log'
|
|
2
2
|
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
3
3
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
4
|
import { isRecord } from '../shared/is-record'
|
|
@@ -86,7 +86,7 @@ const lastWorktreeIn = (text: string, worktreeDirectory: string): string | null
|
|
|
86
86
|
*/
|
|
87
87
|
const workingDirectory = (tail: readonly string[], worktreeDirectory: string): string | null => {
|
|
88
88
|
const entries = [...tail].reverse().flatMap((line): Record<string, unknown>[] => {
|
|
89
|
-
const entry =
|
|
89
|
+
const entry = keptLogEntry(line)
|
|
90
90
|
return entry === null ? [] : [entry]
|
|
91
91
|
})
|
|
92
92
|
for (const entry of entries) {
|
package/src/plans/symbols.ts
CHANGED
|
@@ -18,7 +18,10 @@ export const statusSymbol = (agent: string | null): string => {
|
|
|
18
18
|
|
|
19
19
|
export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WORD
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/** The marks of "Your turn": what waits on the answer, a file sent to look at, what is planned. */
|
|
22
|
+
export const TURN_MARK = { now: '?', file: '📎', planned: '◷' } as const
|
|
23
|
+
|
|
24
|
+
/** Before a row that opens and closes: lanes, the planned phases, the tasks of a phase. */
|
|
22
25
|
export const CHEVRON = { closed: '▸', open: '▾' } as const
|
|
23
26
|
|
|
24
27
|
/** Before the count under a block that shows all its rows; a click folds it back to five. */
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { paint, visibleLength } from '../terminal/text'
|
|
2
|
+
import { textWidth } from '../terminal/width'
|
|
3
|
+
|
|
4
|
+
/** An opened task as the herd sets it: the lines shown at once, and the lines `▸ n more lines` opens. */
|
|
5
|
+
export type TaskBrief = { shown: string[]; rest: string[] }
|
|
6
|
+
|
|
7
|
+
/** A word as its parts, code or not: a code span and the stop after it are one word. */
|
|
8
|
+
type Word = { text: string; code: boolean }[]
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How an opened task is set: the order up to the first `dashMark`, each sentence on a new line, as many whole
|
|
12
|
+
* sentences as fit in `lines` lines, the origin included, no line wider than `lineWidth`; an origin in parentheses
|
|
13
|
+
* moves to the end of the last line, `originGap` from it. A first sentence longer than `lines` ends in `cut`.
|
|
14
|
+
*/
|
|
15
|
+
export const BRIEF = { lines: 5, lineWidth: 72, dashMark: ' — ', originGap: ' ', cut: '…' } as const
|
|
16
|
+
|
|
17
|
+
const CODE_SPAN = /`([^`]+)`/g
|
|
18
|
+
const BOLD = /\*\*/g
|
|
19
|
+
/**
|
|
20
|
+
* A parenthesis that names a day and month with dots: where a task came from, not what it asks. No digit may follow
|
|
21
|
+
* the second dot, so a version (`1.10.0`) or an IP address is no date.
|
|
22
|
+
*/
|
|
23
|
+
const ORIGIN = /\s*\(([^()]*\b\d{1,2}\.\d{1,2}\.(?!\d)[^()]*)\)/g
|
|
24
|
+
/**
|
|
25
|
+
* A sentence ends before a capital after a word of two letters or more, or after a unit behind a number (`5 s.`), so
|
|
26
|
+
* a dotted date and `z. B.` go on.
|
|
27
|
+
*/
|
|
28
|
+
const SENTENCE_END = /(?:(?<=[^\s\d][\p{L})"“”`'][.!?])|(?<=\d \p{L}[.!?]))\s+(?=[\p{Lu}„"`*(])/u
|
|
29
|
+
const SOURCE_DIRECTORY = /^src\//
|
|
30
|
+
|
|
31
|
+
/** The words of `text` at its spaces, code spans without backticks and a path's leading `src/` dropped. */
|
|
32
|
+
const wordsOf = (text: string): Word[] => {
|
|
33
|
+
const words: Word[] = [[]]
|
|
34
|
+
text
|
|
35
|
+
.replace(BOLD, '')
|
|
36
|
+
.split(CODE_SPAN)
|
|
37
|
+
.forEach((segment, index) => {
|
|
38
|
+
const code = index % 2 === 1
|
|
39
|
+
const parts = (code ? segment.replace(SOURCE_DIRECTORY, '') : segment).split(' ')
|
|
40
|
+
parts.forEach((part, at) => {
|
|
41
|
+
if (at > 0) words.push([])
|
|
42
|
+
if (part) words.at(-1)?.push({ text: part, code })
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
return words.filter((word) => word.length > 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lengthOf = (word: Word): number => word.reduce((sum, part) => sum + textWidth(part.text), 0)
|
|
49
|
+
|
|
50
|
+
const drawn = (word: Word): string => word.map(({ text, code }) => (code ? paint.teal(text) : text)).join('')
|
|
51
|
+
|
|
52
|
+
/** `text` broken at spaces into lines of at most `width` cells, code in teal; a word wider than a line stands alone. */
|
|
53
|
+
const setLines = (text: string, width: number): string[] => {
|
|
54
|
+
const lines: Word[][] = []
|
|
55
|
+
let length = 0
|
|
56
|
+
for (const word of wordsOf(text)) {
|
|
57
|
+
const current = lines.at(-1)
|
|
58
|
+
if (current && length + 1 + lengthOf(word) <= width) {
|
|
59
|
+
current.push(word)
|
|
60
|
+
length += 1 + lengthOf(word)
|
|
61
|
+
} else {
|
|
62
|
+
lines.push([word])
|
|
63
|
+
length = lengthOf(word)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return lines.map((line) => line.map(drawn).join(' '))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sentencesOf = (text: string): string[] =>
|
|
70
|
+
text
|
|
71
|
+
.split(SENTENCE_END)
|
|
72
|
+
.map((sentence) => sentence.trim())
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `lines` with the origins dim at the end of the last one, or below it on lines of their own, broken at `width`, where
|
|
77
|
+
* they do not fit beside it.
|
|
78
|
+
*/
|
|
79
|
+
const withOrigins = (lines: string[], origins: readonly string[], width: number): string[] => {
|
|
80
|
+
if (origins.length === 0) return lines
|
|
81
|
+
const origin = origins.join('; ')
|
|
82
|
+
const last = lines.at(-1)
|
|
83
|
+
if (last !== undefined && visibleLength(last) + BRIEF.originGap.length + textWidth(origin) <= width)
|
|
84
|
+
return [...lines.slice(0, -1), `${last}${BRIEF.originGap}${paint.dim(origin)}`]
|
|
85
|
+
return [...lines, ...setLines(origin, width).map((line) => paint.dim(line))]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const originsOf = (text: string): string[] => [...text.matchAll(ORIGIN)].map((match) => match[1]?.trim() ?? '')
|
|
89
|
+
|
|
90
|
+
/** `text` in the form of the brief: one sentence per line, its origins dim at the end. */
|
|
91
|
+
const setSentences = (text: string, width: number): string[] =>
|
|
92
|
+
withOrigins(
|
|
93
|
+
sentencesOf(text.replace(ORIGIN, '')).flatMap((sentence) => setLines(sentence, width)),
|
|
94
|
+
originsOf(text),
|
|
95
|
+
width,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const fits = (lines: string[], origins: readonly string[], width: number): boolean =>
|
|
99
|
+
withOrigins(lines, origins, width).length <= BRIEF.lines
|
|
100
|
+
|
|
101
|
+
/** How many of the leading sentences, each set as `lines`, fit whole into the brief together with the origins. */
|
|
102
|
+
const wholeSentenceCount = (sentenceLines: string[][], origins: readonly string[], width: number): number => {
|
|
103
|
+
let count = 0
|
|
104
|
+
while (count < sentenceLines.length && fits(sentenceLines.slice(0, count + 1).flat(), origins, width)) count += 1
|
|
105
|
+
return count
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A sentence too long for the brief: as many of its lines as fit beside the origins, the last ending in `BRIEF.cut`. */
|
|
109
|
+
const cutSentence = (sentence: string, origins: readonly string[], width: number): TaskBrief => {
|
|
110
|
+
const lines = setLines(sentence, Math.max(width - textWidth(BRIEF.cut), 1))
|
|
111
|
+
const marked = (count: number): string[] =>
|
|
112
|
+
lines.slice(0, count).map((line, index) => (index === count - 1 ? `${line}${BRIEF.cut}` : line))
|
|
113
|
+
let count = BRIEF.lines
|
|
114
|
+
while (count > 1 && !fits(marked(count), origins, width)) count -= 1
|
|
115
|
+
return { shown: withOrigins(marked(count), origins, width), rest: lines.slice(count) }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* An opened task's text (`body`, after its name) set for reading in `width` cells: the order up to the first ` — `,
|
|
120
|
+
* one sentence per line, as many whole sentences as fit into `BRIEF.lines` with their origins dim at the end; the rest
|
|
121
|
+
* (further sentences, then everything after the dash: protocol, measurements, findings) as `rest`. A first sentence
|
|
122
|
+
* that does not fit alone is cut instead. The plan text itself stays as written.
|
|
123
|
+
*/
|
|
124
|
+
export const taskBrief = (body: string, width: number): TaskBrief => {
|
|
125
|
+
const lineWidth = Math.max(Math.min(BRIEF.lineWidth, width), 1)
|
|
126
|
+
const dash = body.indexOf(BRIEF.dashMark)
|
|
127
|
+
const order = dash === -1 ? body : body.slice(0, dash)
|
|
128
|
+
const after = dash === -1 ? '' : body.slice(dash + BRIEF.dashMark.length).trim()
|
|
129
|
+
const origins = originsOf(order)
|
|
130
|
+
const sentences = sentencesOf(order.replace(ORIGIN, ''))
|
|
131
|
+
const sentenceLines = sentences.map((sentence) => setLines(sentence, lineWidth))
|
|
132
|
+
const afterLines = after ? setSentences(after, lineWidth) : []
|
|
133
|
+
const count = wholeSentenceCount(sentenceLines, origins, lineWidth)
|
|
134
|
+
const [first] = sentences
|
|
135
|
+
if (count === 0 && first !== undefined) {
|
|
136
|
+
const cut = cutSentence(first, origins, lineWidth)
|
|
137
|
+
return { shown: cut.shown, rest: [...cut.rest, ...sentenceLines.slice(1).flat(), ...afterLines] }
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
shown: withOrigins(sentenceLines.slice(0, count).flat(), origins, lineWidth),
|
|
141
|
+
rest: [...sentenceLines.slice(count).flat(), ...afterLines],
|
|
142
|
+
}
|
|
143
|
+
}
|