jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { clockText } from '../plan-format/clock'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
|
|
4
|
+
import { isRecordId } from './record-id'
|
|
5
|
+
import { isStillWaiting } from './waiting-window'
|
|
6
|
+
|
|
7
|
+
/** The record of a question that ended without an answer, as the kit hook `open-question` writes it to `.jackherd/questions/`. */
|
|
8
|
+
export type QuestionRecord = {
|
|
9
|
+
id: string
|
|
10
|
+
question: string
|
|
11
|
+
askedAt: number
|
|
12
|
+
cwd: string
|
|
13
|
+
session: string
|
|
14
|
+
closedAt: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether `value` has the shape of a question record's id: twelve lower-case hex digits. */
|
|
18
|
+
export const isQuestionId = isRecordId
|
|
19
|
+
|
|
20
|
+
/** The same shape as `isQuestionRecord` in the hook, which this package does not import at run time (contract test). */
|
|
21
|
+
export const isQuestionRecord = (value: unknown): value is QuestionRecord => {
|
|
22
|
+
if (typeof value !== 'object' || value === null) return false
|
|
23
|
+
const record = value as Record<string, unknown>
|
|
24
|
+
return (
|
|
25
|
+
typeof record.id === 'string' &&
|
|
26
|
+
isQuestionId(record.id) &&
|
|
27
|
+
typeof record.question === 'string' &&
|
|
28
|
+
typeof record.askedAt === 'number' &&
|
|
29
|
+
typeof record.cwd === 'string' &&
|
|
30
|
+
typeof record.session === 'string' &&
|
|
31
|
+
(record.closedAt === null || typeof record.closedAt === 'number')
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const itemOf = (record: QuestionRecord): WaitingItem => ({
|
|
36
|
+
since: clockText(record.askedAt),
|
|
37
|
+
item: terminalSafe(record.question),
|
|
38
|
+
kind: WAITING_KIND.question,
|
|
39
|
+
id: record.id,
|
|
40
|
+
open: true,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** What the unanswered questions wait on: one open item per record younger than three days, oldest first. */
|
|
44
|
+
export const questionWaitingItems = (records: readonly QuestionRecord[], now: number): WaitingItem[] =>
|
|
45
|
+
records
|
|
46
|
+
.filter(({ closedAt, askedAt }) => isStillWaiting(closedAt, askedAt, now))
|
|
47
|
+
.sort((left, right) => left.askedAt - right.askedAt)
|
|
48
|
+
.map(itemOf)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { closableRecordLog } from './closable-records'
|
|
2
|
+
import { isQuestionRecord } from './question-records'
|
|
3
|
+
|
|
4
|
+
const log = closableRecordLog('questions', isQuestionRecord, ({ askedAt }) => askedAt)
|
|
5
|
+
|
|
6
|
+
/** Every record of the question log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
|
|
7
|
+
export const readQuestionRecords = log.read
|
|
8
|
+
|
|
9
|
+
/** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
|
|
10
|
+
export const closeQuestionRecords = log.close
|
|
11
|
+
|
|
12
|
+
/** Marks every open record `closedAt` and returns how many it closed. */
|
|
13
|
+
export const closeAllQuestionRecords = log.closeAll
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { StatCache } from '../shared/stat-cache'
|
|
2
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
3
|
+
import type { PlanCache } from './plan-cache'
|
|
4
|
+
|
|
5
|
+
/** What the herd keeps between its reads, so session logs and plans that did not change are not parsed again. */
|
|
6
|
+
export type ReadCache = { signals: StatCache<TaskSignal[]>; plans: PlanCache }
|
|
7
|
+
|
|
8
|
+
export const newReadCache = (): ReadCache => ({ signals: new Map(), plans: new Map() })
|
|
@@ -2,6 +2,12 @@ import { constants } from 'node:os'
|
|
|
2
2
|
|
|
3
3
|
export const CHILD_MARKER = 'JACKHERD_PLANS_CHILD'
|
|
4
4
|
|
|
5
|
+
/** The signal a newer herd view sends the older one of its terminal, so the older one ends as a wanted end. */
|
|
6
|
+
export const TAKEOVER_SIGNAL = 'SIGUSR2'
|
|
7
|
+
|
|
8
|
+
/** The exit code of a view that ended because a newer herd took its terminal over. */
|
|
9
|
+
export const TAKEN_OVER_CODE = 75
|
|
10
|
+
|
|
5
11
|
/** Milliseconds a burst of file changes is collapsed into one restart. */
|
|
6
12
|
export const RESTART_DEBOUNCE_MS = 300
|
|
7
13
|
|
|
@@ -26,12 +32,15 @@ export type NextStep = 'respawn' | 'wait' | 'exit'
|
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
34
|
* What the supervisor does once the child has ended. Only a wanted end stops it: a forwarded
|
|
29
|
-
* signal (`stopping`)
|
|
30
|
-
*
|
|
35
|
+
* signal (`stopping`), a takeover by a newer herd (the exit code `TAKEN_OVER_CODE`, or the end by SIGKILL or
|
|
36
|
+
* `TAKEOVER_SIGNAL` that the newer herd causes when the child does not answer in time) or a clean exit code 0 (`q`,
|
|
37
|
+
* Ctrl-C in raw mode). A restart we asked for respawns, even when our own stopper had to SIGKILL the child; any other
|
|
38
|
+
* end is a crash, and the supervisor waits for the next change to the source.
|
|
31
39
|
*/
|
|
32
|
-
export const nextStep = ({ restarting, stopping, code }: ChildExit): NextStep => {
|
|
33
|
-
if (stopping) return 'exit'
|
|
40
|
+
export const nextStep = ({ restarting, stopping, code, signal }: ChildExit): NextStep => {
|
|
41
|
+
if (stopping || code === TAKEN_OVER_CODE) return 'exit'
|
|
34
42
|
if (restarting) return 'respawn'
|
|
43
|
+
if (signal === 'SIGKILL' || signal === TAKEOVER_SIGNAL) return 'exit'
|
|
35
44
|
return code === 0 ? 'exit' : 'wait'
|
|
36
45
|
}
|
|
37
46
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { boardRowKey } from './ask-target'
|
|
4
|
+
import { closeAppointmentRow, closeWaitingRow } from './board-close'
|
|
5
|
+
import { errorText } from './error-text'
|
|
6
|
+
import { isQuestionId } from './question-records'
|
|
7
|
+
import { DONE_KIND, type DoneTarget } from './row-targets'
|
|
8
|
+
import { TEXTS } from './texts'
|
|
9
|
+
|
|
10
|
+
/** What `closeRow` needs from the outside: the board file, its language, the clock, and the closing of log records. */
|
|
11
|
+
export type CloseDependencies = {
|
|
12
|
+
sprintFile: string
|
|
13
|
+
language: PlanLanguage
|
|
14
|
+
now: Date
|
|
15
|
+
/** Closes the records of the files and questions log by id; how many it closed. */
|
|
16
|
+
closeRecords: (ids: readonly string[]) => number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const closeRecord = (id: string, { closeRecords }: CloseDependencies): string => {
|
|
20
|
+
if (!isQuestionId(id)) return TEXTS.notClosed('not a record id')
|
|
21
|
+
return closeRecords([id]) > 0 ? TEXTS.closed(`record ${id}`) : TEXTS.notClosed('record not found or closed already')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const closedBoard = (target: DoneTarget, board: string, { language, now }: CloseDependencies): string | null => {
|
|
25
|
+
if (target.kind === DONE_KIND.boardRow) return closeWaitingRow(board, language, target, now)
|
|
26
|
+
return target.kind === DONE_KIND.appointment ? closeAppointmentRow(board, language, target, now) : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const subjectOfRow = (target: DoneTarget): string => {
|
|
30
|
+
if (target.kind === DONE_KIND.boardRow) return `board row ${boardRowKey(target.since, target.item)}`
|
|
31
|
+
return target.kind === DONE_KIND.appointment ? `appointment ${boardRowKey(target.when, target.action)}` : ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): string => {
|
|
35
|
+
try {
|
|
36
|
+
const board = readFileSync(dependencies.sprintFile, 'utf8')
|
|
37
|
+
const closed = closedBoard(target, board, dependencies)
|
|
38
|
+
if (closed === null) return TEXTS.notClosed('row not found in the board')
|
|
39
|
+
writeFileSync(dependencies.sprintFile, closed)
|
|
40
|
+
return TEXTS.closed(subjectOfRow(target))
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return TEXTS.notClosed(errorText(error))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Closes what a `[done]` button names: a log record by its id, or a row of the board's file by writing the stamp into its
|
|
48
|
+
* status cell. Returns the footer line. The board is read again just before it is written, so a row the coordinator
|
|
49
|
+
* changed since the herd drew it is found by its text or not at all.
|
|
50
|
+
*/
|
|
51
|
+
export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): string =>
|
|
52
|
+
target.kind === DONE_KIND.record ? closeRecord(target.id, dependencies) : closeBoardRow(target, dependencies)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ESC, RESET, stripAnsi, visibleLength } from '../terminal/text'
|
|
2
|
+
import { textWidth } from '../terminal/width'
|
|
3
|
+
import { isChipKey } from './link-chips'
|
|
4
|
+
import type { ClickTarget, MouseEvent, Selection } from './selection'
|
|
5
|
+
import type { BlockHit, BlockRow } from './waiting-click'
|
|
6
|
+
|
|
7
|
+
/** A clickable row on screen: its key, the screen line (0-based, header lines first) it starts on and how many lines it takes. */
|
|
8
|
+
export type FocusHit = { key: string; line: number; lines: number }
|
|
9
|
+
|
|
10
|
+
export const FOCUS_MARK = '❯'
|
|
11
|
+
export const FOCUS_ON = `${ESC}[48;5;238m`
|
|
12
|
+
|
|
13
|
+
const BACKGROUND_LOST = new RegExp(`${ESC}\\[(?:0|49)?m`, 'g')
|
|
14
|
+
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, the bar of a block, the closed chevron; an open chevron or a tree branch says more. */
|
|
16
|
+
const MARKABLE_LEADS = new Set([' ', '▌', '▸'])
|
|
17
|
+
|
|
18
|
+
const markedLead = (line: string): string =>
|
|
19
|
+
line.replace(LEADING_STYLES, (whole, styles: string, lead: string) =>
|
|
20
|
+
MARKABLE_LEADS.has(lead) ? `${styles}${FOCUS_MARK}` : whole,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 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, blank or closed chevron that leads it. The background is laid again after
|
|
26
|
+
* every reset inside the line, so styled text and buttons keep it.
|
|
27
|
+
*/
|
|
28
|
+
export const focusLine = (line: string, width: number, marked: boolean): string => {
|
|
29
|
+
const shown = marked ? markedLead(line) : line
|
|
30
|
+
const padded = `${shown}${' '.repeat(Math.max(width - visibleLength(shown), 0))}`
|
|
31
|
+
return `${FOCUS_ON}${padded.replace(BACKGROUND_LOST, (reset) => `${reset}${FOCUS_ON}`)}${RESET}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The row on screen line `line`, null where none is. */
|
|
35
|
+
export const focusHitAt = (hits: readonly FocusHit[], line: number): FocusHit | null =>
|
|
36
|
+
hits.find((hit) => line >= hit.line && line < hit.line + hit.lines) ?? null
|
|
37
|
+
|
|
38
|
+
/** The screen a motion report is looked up on: header lines, the body's rows and how far it is scrolled. */
|
|
39
|
+
export type HoverFrame = { headerRows: number; bodyRows: number; top: number }
|
|
40
|
+
|
|
41
|
+
/** The screen line (0-based, header first, the body unscrolled) under a motion report; null in the footer. */
|
|
42
|
+
export const lineIndexAt = ({ row }: MouseEvent, { headerRows, bodyRows, top }: HoverFrame): number | null => {
|
|
43
|
+
const line = row - 1
|
|
44
|
+
if (line < headerRows) return line
|
|
45
|
+
const bodyLine = line - headerRows
|
|
46
|
+
return bodyLine < bodyRows ? headerRows + top + bodyLine : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The key of the row under the mouse, null over none. */
|
|
50
|
+
export const hoveredKeyAt = (hits: readonly FocusHit[], mouse: MouseEvent, frame: HoverFrame): string | null => {
|
|
51
|
+
const line = lineIndexAt(mouse, frame)
|
|
52
|
+
return line === null ? null : (focusHitAt(hits, line)?.key ?? null)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const BRACKET_BUTTON = /\[[^[\]]+\]/g
|
|
56
|
+
|
|
57
|
+
/** The columns `[from, to)` of the square-bracket buttons in the visible text of a line. */
|
|
58
|
+
export const bracketSpans = (visible: string): { from: number; to: number }[] =>
|
|
59
|
+
[...visible.matchAll(BRACKET_BUTTON)].map((match) => {
|
|
60
|
+
const from = textWidth(visible.slice(0, match.index))
|
|
61
|
+
return { from, to: from + textWidth(match[0]) }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/** The column a square-bracket button under `column` starts in, null where the mouse is over none. */
|
|
65
|
+
export const bracketAt = (visible: string, column: number): number | null =>
|
|
66
|
+
bracketSpans(visible).find((span) => column >= span.from && column < span.to)?.from ?? null
|
|
67
|
+
|
|
68
|
+
const STYLE_OR_LINK = new RegExp(`(${ESC}\\[[0-9;]*m|${ESC}\\]8;[^;${ESC}\\x07]*;[^${ESC}\\x07]*(?:${ESC}\\\\|\\x07))`)
|
|
69
|
+
const BOLD_ON = `${ESC}[1m`
|
|
70
|
+
const BOLD_OFF = `${ESC}[22m`
|
|
71
|
+
|
|
72
|
+
/** The line with the cells `[from, to)` bold, counted by what shows; styles and links in the line stay where they are. */
|
|
73
|
+
export const boldCells = (line: string, from: number, to: number): string => {
|
|
74
|
+
let column = 0
|
|
75
|
+
return line
|
|
76
|
+
.split(STYLE_OR_LINK)
|
|
77
|
+
.map((token, index) => {
|
|
78
|
+
if (index % 2 === 1) return token
|
|
79
|
+
return [...token]
|
|
80
|
+
.map((character) => {
|
|
81
|
+
const start = column
|
|
82
|
+
column += textWidth(character)
|
|
83
|
+
const opens = start === from ? BOLD_ON : ''
|
|
84
|
+
const closes = column >= to && start < to ? BOLD_OFF : ''
|
|
85
|
+
return `${opens}${character}${closes}`
|
|
86
|
+
})
|
|
87
|
+
.join('')
|
|
88
|
+
})
|
|
89
|
+
.join('')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The line with the square-bracket button that starts in column `from` bold; the line itself where none does. */
|
|
93
|
+
export const boldBracketAt = (line: string, from: number): string => {
|
|
94
|
+
const span = bracketSpans(stripAnsi(line)).find((each) => each.from === from)
|
|
95
|
+
return span ? boldCells(line, span.from, span.to) : line
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 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
|
+
export const LINKS_KEY_PREFIX = 'links@'
|
|
100
|
+
|
|
101
|
+
/** The selection with the row under the mouse and the button in it set; the selection itself while both are the same. */
|
|
102
|
+
export const withHovered = (selection: Selection, key: string | null, from: number | null = null): Selection =>
|
|
103
|
+
(selection.hovered ?? null) === key && (selection.hoveredFrom ?? null) === from
|
|
104
|
+
? selection
|
|
105
|
+
: { ...selection, hovered: key, hoveredFrom: from }
|
|
106
|
+
|
|
107
|
+
/** Whether a row is the selected one or the one under the mouse. */
|
|
108
|
+
export const isFocused = ({ selected, hovered }: Selection, key: string): boolean => key === selected || key === hovered
|
|
109
|
+
|
|
110
|
+
/** Whether the buttons of a row are all bold: it is the row the arrows selected, and the mouse is not on it. */
|
|
111
|
+
export const buttonsAllBold = ({ selected, hovered }: Selection, key: string): boolean =>
|
|
112
|
+
key === selected && key !== hovered
|
|
113
|
+
|
|
114
|
+
/** The row of a drawn block that has `key`, null for a key of the body. */
|
|
115
|
+
export const blockRowByKey = (blocks: readonly (BlockHit | null)[], key: string): BlockRow | null =>
|
|
116
|
+
blocks.flatMap((block) => block?.rows ?? []).find((row) => row.key === key) ?? null
|
|
117
|
+
|
|
118
|
+
/** The rows of a block on top of the herd, from the screen line of its first row; none while the block is not drawn or folded. */
|
|
119
|
+
export const focusHitsOfBlock = (block: BlockHit | null): FocusHit[] => {
|
|
120
|
+
let line = block?.firstLine ?? 0
|
|
121
|
+
return (block?.rows ?? []).map((row) => {
|
|
122
|
+
const hit = { key: row.key, line, lines: row.lines.length }
|
|
123
|
+
line += row.lines.length
|
|
124
|
+
return hit
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The rows of a body from the lines its whole-line targets span, `offset` screen lines below the top; a chip is part of
|
|
130
|
+
* its row. A line with chips and no row of its own (the links under a lane) is a hit under a key made of its line.
|
|
131
|
+
*/
|
|
132
|
+
export const focusHitsOfTargets = (targets: readonly ClickTarget[], offset: number): FocusHit[] => {
|
|
133
|
+
const hits = new Map<string, FocusHit>()
|
|
134
|
+
for (const { key, line, wholeLine } of targets) {
|
|
135
|
+
if (!wholeLine || isChipKey(key)) continue
|
|
136
|
+
const known = hits.get(key)
|
|
137
|
+
hits.set(
|
|
138
|
+
key,
|
|
139
|
+
known ? { ...known, lines: line + 1 - (known.line - offset) } : { key, line: offset + line, lines: 1 },
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
const rowLines = new Set(targets.filter(({ wholeLine }) => wholeLine).map(({ line }) => line))
|
|
143
|
+
const linkLines = [...new Set(targets.filter(({ key }) => isChipKey(key)).map(({ line }) => line))]
|
|
144
|
+
const links = linkLines
|
|
145
|
+
.filter((line) => !rowLines.has(line))
|
|
146
|
+
.map((line) => ({ key: `${LINKS_KEY_PREFIX}${offset + line}`, line: offset + line, lines: 1 }))
|
|
147
|
+
return [...hits.values(), ...links]
|
|
148
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** What a `[done]` button closes: a record of the files and questions log, a row of the waiting section, an appointment row. */
|
|
2
|
+
export const DONE_KIND = { record: 'record', boardRow: 'boardRow', appointment: 'appointment' } as const
|
|
3
|
+
|
|
4
|
+
export type DoneTarget =
|
|
5
|
+
| { kind: typeof DONE_KIND.record; id: string }
|
|
6
|
+
| { kind: typeof DONE_KIND.boardRow; since: string; item: string }
|
|
7
|
+
| { kind: typeof DONE_KIND.appointment; when: string; action: string }
|
package/src/plans/selection.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Whether a lane is drawn open. It is decided once, when the lane first appears (`autoOpen`, by its status then), and
|
|
@@ -8,6 +9,10 @@ import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
|
|
|
8
9
|
*/
|
|
9
10
|
export type LaneFold = { open: boolean }
|
|
10
11
|
|
|
12
|
+
/** The blocks on top of the herd, by the name their fold is remembered under. */
|
|
13
|
+
export const TOP_BLOCK = { waiting: 'waiting', next: 'next', appointments: 'appointments', sources: 'sources' } as const
|
|
14
|
+
export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
|
|
13
18
|
* listed one by one or summed up in one line, whether the footer shows the symbol legend, and the
|
|
@@ -15,10 +20,16 @@ export type LaneFold = { open: boolean }
|
|
|
15
20
|
*/
|
|
16
21
|
export type Selection = {
|
|
17
22
|
selected: string | null
|
|
23
|
+
/** The row under the mouse, null or absent while it is over none; drawn like the selected one. */
|
|
24
|
+
hovered?: string | null
|
|
25
|
+
/** The column the square-bracket button under the mouse starts in; null or absent while the mouse is over none. */
|
|
26
|
+
hoveredFrom?: number | null
|
|
18
27
|
expanded: ReadonlySet<string>
|
|
19
28
|
showDone: boolean
|
|
20
29
|
showLegend: boolean
|
|
21
30
|
laneFolds: ReadonlyMap<string, LaneFold>
|
|
31
|
+
/** Per folded top block, the keys of its points seen when it was folded; a block that is open has no entry. */
|
|
32
|
+
blockFolds?: Readonly<Partial<Record<TopBlockId, ReadonlySet<string>>>>
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
/** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
|
|
@@ -70,12 +81,25 @@ export const PLANNED_KEY = 'planned'
|
|
|
70
81
|
|
|
71
82
|
export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
|
|
72
83
|
|
|
84
|
+
const PHASE_KEY_SHAPE = '[^@>#]+:\\d+:[^:#>]+'
|
|
85
|
+
|
|
86
|
+
const PHASE_KEY = new RegExp(`^${PHASE_KEY_SHAPE}$`)
|
|
87
|
+
|
|
88
|
+
/** Whether a key names a phase: not a lane, row or task key, which carry `@`, `>` or `#`. */
|
|
89
|
+
const isPhaseKey = (key: string): boolean => PHASE_KEY.test(key)
|
|
90
|
+
|
|
91
|
+
/** The key of a lane's `next` row, which opens to the phases that wait. */
|
|
92
|
+
export const nextKey = (lane: string): string => `next>${lane}`
|
|
93
|
+
|
|
94
|
+
/** The key of a lane's `✓` row, which opens to the finished phases. */
|
|
95
|
+
export const finishedKey = (lane: string): string => `done>${lane}`
|
|
96
|
+
|
|
73
97
|
const TICKED_KEY_PREFIX = 'ticked>'
|
|
74
98
|
|
|
75
99
|
/** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
|
|
76
100
|
export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
|
|
77
101
|
|
|
78
|
-
const ITEM_KEY =
|
|
102
|
+
const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
|
|
79
103
|
|
|
80
104
|
/**
|
|
81
105
|
* The key of a task row: its phase's key and the task's index in `phase.items`; a click or Enter opens its text.
|
|
@@ -173,13 +197,10 @@ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => b
|
|
|
173
197
|
return { ...selection, laneFolds: new Map(selection.laneFolds).set(lane, { ...fold, open: next(fold.open) }) }
|
|
174
198
|
}
|
|
175
199
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Selects a row and opens it, or closes it when it is already open: a phase shows its items, a lane its phases. */
|
|
200
|
+
/**
|
|
201
|
+
* Selects the row under a click. A "N ticked" line flips `showDone` and selects its phase, a lane line flips the
|
|
202
|
+
* fold of its lane, any other row opens when closed and closes when open: a phase shows its items.
|
|
203
|
+
*/
|
|
183
204
|
export const applyClick = (selection: Selection, key: string): Selection => {
|
|
184
205
|
const phase = tickedPhaseOfKey(key)
|
|
185
206
|
if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
|
|
@@ -187,7 +208,7 @@ export const applyClick = (selection: Selection, key: string): Selection => {
|
|
|
187
208
|
if (lane !== null) return { ...foldLane(selection, lane, flipped), selected: key }
|
|
188
209
|
const expanded = new Set(selection.expanded)
|
|
189
210
|
const itemPhase = itemPhaseOfKey(key)
|
|
190
|
-
if (itemPhase !== null && !expanded.has(itemPhase))
|
|
211
|
+
if (itemPhase !== null && !expanded.has(itemPhase)) addWithPhase(expanded, key)
|
|
191
212
|
else toggleIn(expanded, key)
|
|
192
213
|
return { ...selection, selected: key, expanded }
|
|
193
214
|
}
|
|
@@ -197,17 +218,30 @@ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
|
|
|
197
218
|
return current ? applyClick(selection, current) : selection
|
|
198
219
|
}
|
|
199
220
|
|
|
200
|
-
|
|
221
|
+
/** Adds a key to the expanded ones, with the phase of a task. */
|
|
222
|
+
const addWithPhase = (expanded: Set<string>, key: string): void => {
|
|
223
|
+
expanded.add(key)
|
|
224
|
+
const itemPhase = itemPhaseOfKey(key)
|
|
225
|
+
if (itemPhase !== null) expanded.add(itemPhase)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const removeKey = (expanded: Set<string>, key: string): void => {
|
|
229
|
+
expanded.delete(key)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Applies a change to the selected row: `nextFold` to the fold of a lane, `change` to the expanded keys for any other row. */
|
|
233
|
+
const changeSelected = (
|
|
234
|
+
selection: Selection,
|
|
235
|
+
keys: readonly string[],
|
|
236
|
+
nextFold: (open: boolean) => boolean,
|
|
237
|
+
change: (expanded: Set<string>, key: string) => void,
|
|
238
|
+
): Selection => {
|
|
201
239
|
const current = selectedKey(selection, keys)
|
|
202
240
|
if (!current) return selection
|
|
203
241
|
const lane = laneOfKey(current)
|
|
204
|
-
if (lane !== null) return { ...foldLane(selection, lane,
|
|
242
|
+
if (lane !== null) return { ...foldLane(selection, lane, nextFold), selected: current }
|
|
205
243
|
const expanded = new Set(selection.expanded)
|
|
206
|
-
|
|
207
|
-
expanded.add(current)
|
|
208
|
-
const itemPhase = itemPhaseOfKey(current)
|
|
209
|
-
if (itemPhase !== null) expanded.add(itemPhase)
|
|
210
|
-
} else expanded.delete(current)
|
|
244
|
+
change(expanded, current)
|
|
211
245
|
return { ...selection, selected: current, expanded }
|
|
212
246
|
}
|
|
213
247
|
|
|
@@ -218,9 +252,59 @@ export const toggledExpansion = (selection: Selection, key: string): Selection =
|
|
|
218
252
|
return { ...selection, expanded }
|
|
219
253
|
}
|
|
220
254
|
|
|
221
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Whether `block` is drawn folded: it was folded and every point now open was there then. A point that arrived since
|
|
257
|
+
* opens it again, so folding never hides something new. Each block remembers its own points.
|
|
258
|
+
*/
|
|
259
|
+
export const isBlockFolded = ({ blockFolds }: Selection, block: TopBlockId, keys: readonly string[]): boolean => {
|
|
260
|
+
const folded = blockFolds?.[block]
|
|
261
|
+
return folded !== undefined && keys.every((key) => folded.has(key))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The selection with the fold of each block forgotten that a new point opened, given the points each block holds now.
|
|
266
|
+
* Without it a block that opened for a new point would fold again by itself once that point is gone. The selection
|
|
267
|
+
* itself when no fold is forgotten.
|
|
268
|
+
*/
|
|
269
|
+
export const settledBlockFolds = (
|
|
270
|
+
selection: Selection,
|
|
271
|
+
keysOf: Readonly<Record<TopBlockId, readonly string[]>>,
|
|
272
|
+
): Selection => {
|
|
273
|
+
const folds = selection.blockFolds ?? {}
|
|
274
|
+
const opened = Object.values(TOP_BLOCK).filter(
|
|
275
|
+
(block) => folds[block] !== undefined && !isBlockFolded(selection, block, keysOf[block]),
|
|
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
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The selection with `block` folded over the points `keys` when it was open, and open when it was folded. */
|
|
284
|
+
export const toggledBlockFold = (selection: Selection, block: TopBlockId, keys: readonly string[]): Selection => {
|
|
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
|
+
}
|
|
222
292
|
|
|
223
|
-
const
|
|
293
|
+
const expand = (selection: Selection, keys: readonly string[]): Selection =>
|
|
294
|
+
changeSelected(selection, keys, opened, addWithPhase)
|
|
295
|
+
|
|
296
|
+
const collapse = (selection: Selection, keys: readonly string[]): Selection =>
|
|
297
|
+
changeSelected(selection, keys, closed, removeKey)
|
|
298
|
+
|
|
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
|
+
}
|
|
224
308
|
|
|
225
309
|
export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
|
|
226
310
|
if (isInput(INPUT.up, input)) return move(selection, keys, -1)
|
|
@@ -233,6 +317,9 @@ export const applyKey = (selection: Selection, input: string, keys: readonly str
|
|
|
233
317
|
return selection
|
|
234
318
|
}
|
|
235
319
|
|
|
320
|
+
/** Whether an input is enter or space, which acts on the selected row. */
|
|
321
|
+
export const isActivateKey = (input: string): boolean => isInput(INPUT.toggle, input)
|
|
322
|
+
|
|
236
323
|
/** The key that writes the whole sprint as an HTML page and opens it. */
|
|
237
324
|
export const isSprintPageKey = (input: string): boolean => isInput(INPUT.sprintPage, input)
|
|
238
325
|
|
|
@@ -242,6 +329,12 @@ export const parseMouse = (key: string): MouseEvent | null => {
|
|
|
242
329
|
return { button: Number(match[1]), column: Number(match[2]), row: Number(match[3]), pressed: match[4] === 'M' }
|
|
243
330
|
}
|
|
244
331
|
|
|
332
|
+
const MOTION_BIT = 32
|
|
333
|
+
|
|
334
|
+
/** Whether a report is the mouse moving (with or without a button held), not a click, a release or the wheel. */
|
|
335
|
+
export const isMotion = ({ button, pressed }: MouseEvent): boolean =>
|
|
336
|
+
pressed && button >= MOTION_BIT && button < MOUSE_BUTTON.wheelUp
|
|
337
|
+
|
|
245
338
|
export const targetAt = (targets: readonly ClickTarget[], line: number, column: number): string | null =>
|
|
246
339
|
targets.find((target) => target.line === line && column >= target.from && column < target.to)?.key ?? null
|
|
247
340
|
|
|
@@ -1,22 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { isSentFileRecord, type SentFileRecord } from './sent-files'
|
|
1
|
+
import { closableRecordLog } from './closable-records'
|
|
2
|
+
import { isSentFileRecord } from './sent-files'
|
|
4
3
|
|
|
5
|
-
const
|
|
4
|
+
const log = closableRecordLog('sent', isSentFileRecord, ({ sentAt }) => sentAt)
|
|
6
5
|
|
|
7
6
|
/** Every record of the sent-file log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
|
|
8
|
-
export const readSentRecords =
|
|
9
|
-
readRecords(directoryOf(root), isSentFileRecord, ({ sentAt }) => sentAt)
|
|
10
|
-
|
|
11
|
-
const closeWhere = (root: string, isChosen: (record: SentFileRecord) => boolean, now: number): number => {
|
|
12
|
-
const open = readSentRecords(root).filter((record) => record.closedAt === null && isChosen(record))
|
|
13
|
-
for (const record of open) writeRecordAtomically(directoryOf(root), { ...record, closedAt: now })
|
|
14
|
-
return open.length
|
|
15
|
-
}
|
|
7
|
+
export const readSentRecords = log.read
|
|
16
8
|
|
|
17
9
|
/** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
|
|
18
|
-
export const closeSentRecords =
|
|
19
|
-
closeWhere(root, (record) => ids.includes(record.id), now)
|
|
10
|
+
export const closeSentRecords = log.close
|
|
20
11
|
|
|
21
12
|
/** Marks every open record `closedAt` and returns how many it closed. */
|
|
22
|
-
export const closeAllSentRecords =
|
|
13
|
+
export const closeAllSentRecords = log.closeAll
|
package/src/plans/sent-files.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
|
-
import {
|
|
2
|
+
import { clockText, DAY_MS } from '../plan-format/clock'
|
|
3
3
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
-
import type
|
|
4
|
+
import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
|
|
5
|
+
import { isRecordId } from './record-id'
|
|
5
6
|
import { laneDirectoryOf } from './unlisted-lanes'
|
|
7
|
+
import { isStillWaiting } from './waiting-window'
|
|
6
8
|
|
|
7
9
|
/** The record of a send of files to the user, as the kit hook `sent-file` writes it to `.jackherd/sent/`. */
|
|
8
10
|
export type SentFileRecord = {
|
|
@@ -19,12 +21,8 @@ export type SentFileRecord = {
|
|
|
19
21
|
/** A badge for a sent file: a `file:` address and the file's name. */
|
|
20
22
|
export type SentChip = { url: string; label: string }
|
|
21
23
|
|
|
22
|
-
const ID = /^[0-9a-f]{12}$/
|
|
23
24
|
/** The address of a published artifact, as the hook reads it from the tool's answer, as a whole. */
|
|
24
25
|
const ARTIFACT_ADDRESS = /^https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+$/
|
|
25
|
-
const DAY_MS = 86_400_000
|
|
26
|
-
/** A send waits on the user this long at most; the coordinator closes it earlier, with the answer. */
|
|
27
|
-
const WAITING_DAYS = 3
|
|
28
26
|
const SHOWN_PER_LANE = 5
|
|
29
27
|
|
|
30
28
|
/** A published artifact is recorded by its address; a sent file by its path. */
|
|
@@ -49,7 +47,7 @@ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
|
|
|
49
47
|
const record = value as Record<string, unknown>
|
|
50
48
|
return (
|
|
51
49
|
typeof record.id === 'string' &&
|
|
52
|
-
|
|
50
|
+
isRecordId(record.id) &&
|
|
53
51
|
isStringArray(record.files) &&
|
|
54
52
|
record.files.every(isRecordedFile) &&
|
|
55
53
|
(record.caption === null || typeof record.caption === 'string') &&
|
|
@@ -96,18 +94,13 @@ export const sentChipsByLane = (
|
|
|
96
94
|
return lanes
|
|
97
95
|
}
|
|
98
96
|
|
|
99
|
-
const clockText = (at: number): string => {
|
|
100
|
-
const date = new Date(at)
|
|
101
|
-
return `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
|
|
102
|
-
}
|
|
103
|
-
|
|
104
97
|
const itemOf = (record: SentFileRecord): WaitingItem => {
|
|
105
98
|
const [first] = record.files
|
|
106
99
|
const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
|
|
107
100
|
return {
|
|
108
101
|
since: clockText(record.sentAt),
|
|
109
102
|
item: `${labelOf(first, record.caption)}${more}`,
|
|
110
|
-
kind:
|
|
103
|
+
kind: WAITING_KIND.file,
|
|
111
104
|
id: record.id,
|
|
112
105
|
open: true,
|
|
113
106
|
url: addressOf(first),
|
|
@@ -117,5 +110,5 @@ const itemOf = (record: SentFileRecord): WaitingItem => {
|
|
|
117
110
|
/** What the sends that no one closed wait on: one open item per record younger than three days, oldest first. */
|
|
118
111
|
export const sentWaitingItems = (records: readonly SentFileRecord[], now: number): WaitingItem[] =>
|
|
119
112
|
latestPerFile(records)
|
|
120
|
-
.filter(({ closedAt, sentAt }) => closedAt
|
|
113
|
+
.filter(({ closedAt, sentAt }) => isStillWaiting(closedAt, sentAt, now))
|
|
121
114
|
.map(itemOf)
|