spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

Merge pull request #93912 from anthropics/poteat/mod-tests-seatBrowse files
mods: unit tests for diff, sec-default and telemetry, typed against the plugin declarations
Alice T'Poteat authored on Sep 12, 2026 · GitHub committed on Sep 12, 2026 parents 2b40e76 + c136556 · commit e46ab548ab3f
295 changed files+1532 -1125compared with 2b40e76
A.github/workflows/mod-tests.yml
Mmods/README.md
Mmods/diff/README.md
Amods/diff/hooks/ask/ask-cut-note/ask-cut-note.ts
Amods/diff/hooks/ask/ask-cut-note/index.ts
Amods/diff/hooks/ask/fitted-ask-text-of.ts
Mmods/diff/hooks/ask/index.ts
Mmods/diff/hooks/backend/backend-of.ts
Mmods/diff/hooks/backend/installed-backend-probes.ts
Mmods/diff/hooks/backend/types/backend/backend.ts
Mmods/diff/hooks/backend/types/backend-host/backend-host.ts
Mmods/diff/hooks/backend/types/backend-probe/backend-probe.ts
Mmods/diff/hooks/backend/types/backend-words/backend-words.ts
Mmods/diff/hooks/classify/is-generated-file/is-generated-file.ts
Amods/diff/hooks/git/fetch-diff/fetch-diff.ts
Amods/diff/hooks/git/fetch-diff/index.ts
Dmods/diff/hooks/git/fetch-diff.ts
Amods/diff/hooks/git/fetch-file-hunks/fetch-file-hunks.ts
Amods/diff/hooks/git/fetch-file-hunks/index.ts
Dmods/diff/hooks/git/fetch-file-hunks.ts
Mmods/diff/hooks/git/git-backend-of.ts
Mmods/diff/hooks/git/git-words/git-words.ts
Mmods/diff/hooks/git/index.ts
Mmods/diff/hooks/git/parse/is-whole-listing/is-whole-listing.ts
Mmods/diff/hooks/git/parse/parse-file-diff.ts
Mmods/diff/hooks/git/parse/parse-numstat.ts
Mmods/diff/hooks/git/probes/branch-base-of/branch-base-of.ts
Mmods/diff/hooks/git/probes/current-branch-of/current-branch-of.ts
Mmods/diff/hooks/git/probes/datings-of/datings-of.ts
Mmods/diff/hooks/git/probes/default-branch-of/default-branch-of.ts
Mmods/diff/hooks/git/probes/head-key-of/head-key-of.ts
Mmods/diff/hooks/git/probes/index.ts
Mmods/diff/hooks/git/probes/kind-walk-of/kind-walk-of.ts
Mmods/diff/hooks/git/probes/missing-base-of/missing-base-of.ts
Mmods/diff/hooks/git/probes/overlay-unstaged.ts
Mmods/diff/hooks/git/probes/repository-of/repository-of.ts
Mmods/diff/hooks/git/probes/stats-of.ts
Mmods/diff/hooks/git/probes/tag-pre-session.ts
Mmods/diff/hooks/git/probes/unstaged-numstat.ts
Mmods/diff/hooks/git/probes/untracked-files/untracked-files.ts
Mmods/diff/hooks/git/probes/untracked-rows-of/untracked-rows-of.ts
Mmods/diff/hooks/git/tiers/unborn-tier/unborn-tier.ts
Mmods/diff/hooks/git/tiers/with-untracked/merged-with-untracked/merged-with-untracked.ts
Mmods/diff/hooks/git/tiers/with-untracked/with-untracked.ts
Mmods/diff/hooks/git/tiers/working-tree-tier.ts
Mmods/diff/hooks/git/types/diff-data/diff-data.ts
Mmods/diff/hooks/git/types/diff-source/diff-source.ts
Mmods/diff/hooks/host/host.ts
Mmods/diff/hooks/index.ts
Mmods/diff/hooks/limits/columns/index.ts
Mmods/diff/hooks/limits/columns/open-min-columns.ts
Amods/diff/hooks/limits/columns/pane-right-pad-columns.ts
Amods/diff/hooks/limits/sizes/bytes/host-output-cap-bytes.ts
Amods/diff/hooks/limits/sizes/bytes/index.ts
Amods/diff/hooks/limits/sizes/bytes/max-diff-bytes.ts
Dmods/diff/hooks/limits/sizes/host-output-cap-bytes.ts
Mmods/diff/hooks/limits/sizes/index.ts
Dmods/diff/hooks/limits/sizes/max-diff-bytes.ts
Amods/diff/hooks/limits/sizes/pane-top-pad-rows.ts
Amods/diff/hooks/limits/sizes/prompt-context-max-chars.ts
Mmods/diff/hooks/names/pane-title.ts
Amods/diff/hooks/names/texts/git-unanswered-text.ts
Mmods/diff/hooks/names/texts/index.ts
Mmods/diff/hooks/names/texts/not-in-repository-text.ts
Amods/diff/hooks/names/texts/resize-terminal-text.ts
Amods/diff/hooks/names/texts/untracked-withheld-text-of.ts
Dmods/diff/hooks/names/texts/untracked-withheld-text.ts
Mmods/diff/hooks/pane-state/base-label-of.ts
Mmods/diff/hooks/pane-state/empty-state-of.ts
Mmods/diff/hooks/pane-state/mode-label-of/mode-label-of.ts
Mmods/diff/hooks/pane-state/pane-model/pane-model.ts
Mmods/diff/hooks/pane-toggle/pane-belief/pane-belief.ts
Mmods/diff/hooks/pane-toggle/pane-toggle-of.ts
Amods/diff/hooks/record/base-outcomes/base-outcomes.ts
Amods/diff/hooks/record/base-outcomes/index.ts
Mmods/diff/hooks/record/features/features.ts
Mmods/diff/hooks/record/index.ts
Mmods/diff/hooks/record/mark-outcome/mark-outcome.ts
Mmods/diff/hooks/record/recorder/recorder.ts
Mmods/diff/hooks/record/recorder-of.ts
Amods/diff/hooks/record/tabs/index.ts
Amods/diff/hooks/record/tabs/tabs.ts
Mmods/diff/hooks/record/width-buckets/width-buckets.ts
Mmods/diff/hooks/register.ts
Dmods/diff/hooks/telemetry-noun.d.ts
Mmods/diff/hooks/todos/todo-progress-of.ts
Mmods/diff/hooks/turns/turn-of/file-edit-of/hunks-of/hunks-of.ts
Mmods/diff/hooks/turns/turn-of/turn-of.ts
Mmods/diff/hooks/views/current-pane/current-pane.tsx
Mmods/diff/hooks/views/current-pane/file-row-of/file-row-of.ts
Mmods/diff/hooks/views/current-pane/index.ts
Amods/diff/hooks/views/current-pane/message-pane-of/foot-rows/foot-rows.ts
Amods/diff/hooks/views/current-pane/message-pane-of/foot-rows/index.ts
Amods/diff/hooks/views/current-pane/message-pane-of/index.ts
Amods/diff/hooks/views/current-pane/message-pane-of/message-pane/index.ts
Amods/diff/hooks/views/current-pane/message-pane-of/message-pane/message-pane.ts
Amods/diff/hooks/views/current-pane/message-pane-of/message-pane-of.tsx
Dmods/diff/hooks/views/detail/backgrounds/backgrounds.ts
Dmods/diff/hooks/views/detail/backgrounds/index.ts
Amods/diff/hooks/views/detail/code-blocks-of/code-blocks-of.ts
Amods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/drawn-line-of.ts
Amods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/index.ts
Amods/diff/hooks/views/detail/code-blocks-of/index.ts
Mmods/diff/hooks/views/detail/detail-model/detail-model.ts
Mmods/diff/hooks/views/detail/detail-view.tsx
Dmods/diff/hooks/views/detail/gutter-cells-of/gutter-cells-of.ts
Dmods/diff/hooks/views/detail/gutter-cells-of/index.ts
Dmods/diff/hooks/views/detail/hunk-body/body-cells/body-cells.ts
Dmods/diff/hooks/views/detail/hunk-body/body-cells/index.ts
Dmods/diff/hooks/views/detail/hunk-body/hunk-body.tsx
Dmods/diff/hooks/views/detail/hunk-body/index.ts
Amods/diff/hooks/views/detail/hunk-divider/hunk-divider.ts
Amods/diff/hooks/views/detail/hunk-divider/index.ts
Amods/diff/hooks/views/detail/hunk-divider-nodes/hunk-divider-nodes.ts
Amods/diff/hooks/views/detail/hunk-divider-nodes/index.ts
Amods/diff/hooks/views/detail/hunk-header-of/hunk-header-of.ts
Amods/diff/hooks/views/detail/hunk-header-of/index.ts
Amods/diff/hooks/views/detail/hunk-source-of/hunk-source-of.ts
Amods/diff/hooks/views/detail/hunk-source-of/index.ts
Mmods/diff/hooks/views/detail/index.ts
Amods/diff/hooks/views/detail/leading-hunk-of/index.ts
Amods/diff/hooks/views/detail/leading-hunk-of/leading-hunk-of.ts
Dmods/diff/hooks/views/detail/lines-of/index.ts
Dmods/diff/hooks/views/detail/lines-of/lines-of.ts
Dmods/diff/hooks/views/detail/marker-cells/index.ts
Dmods/diff/hooks/views/detail/marker-cells/marker-cells.ts
Mmods/diff/hooks/views/detail/max-body-chars/max-body-chars.ts
Mmods/diff/hooks/views/detail/max-body-nodes/max-body-nodes.ts
Amods/diff/hooks/views/detail/max-code-chars/index.ts
Amods/diff/hooks/views/detail/max-code-chars/max-code-chars.ts
Dmods/diff/hooks/views/detail/max-rows-of/frame-nodes/frame-nodes.ts
Dmods/diff/hooks/views/detail/max-rows-of/frame-nodes/index.ts
Dmods/diff/hooks/views/detail/max-rows-of/highlight-nodes/highlight-nodes.ts
Dmods/diff/hooks/views/detail/max-rows-of/highlight-nodes/index.ts
Dmods/diff/hooks/views/detail/max-rows-of/index.ts
Dmods/diff/hooks/views/detail/max-rows-of/max-rows-of.ts
Dmods/diff/hooks/views/detail/max-rows-of/run-nodes/index.ts
Dmods/diff/hooks/views/detail/max-rows-of/run-nodes/run-nodes.ts
Dmods/diff/hooks/views/detail/max-run-chars/index.ts
Dmods/diff/hooks/views/detail/max-run-chars/max-run-chars.ts
Mmods/diff/hooks/views/detail/placeholder-of/placeholder-of.ts
Mmods/diff/hooks/views/detail/placeholder-of/safe-pathspec-pattern/safe-pathspec-pattern.ts
Dmods/diff/hooks/views/detail/rows-of/index.ts
Dmods/diff/hooks/views/detail/rows-of/max-highlights/index.ts
Dmods/diff/hooks/views/detail/rows-of/max-highlights/max-highlights.ts
Dmods/diff/hooks/views/detail/rows-of/rows-of.ts
Dmods/diff/hooks/views/detail/rows-of/segments-of/index.ts
Dmods/diff/hooks/views/detail/rows-of/segments-of/segments-of.ts
Dmods/diff/hooks/views/detail/runs-of/index.ts
Dmods/diff/hooks/views/detail/runs-of/runs-of.ts
Amods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/first-line-cut.ts
Amods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/index.ts
Amods/diff/hooks/views/detail/sub-hunks-of/index.ts
Amods/diff/hooks/views/detail/sub-hunks-of/sub-hunks-of.ts
Amods/diff/hooks/views/detail/types/code-block/code-block.ts
Amods/diff/hooks/views/detail/types/code-block/index.ts
Amods/diff/hooks/views/detail/types/code-body/code-body.ts
Amods/diff/hooks/views/detail/types/code-body/index.ts
Dmods/diff/hooks/views/detail/types/diff-line/diff-line.ts
Dmods/diff/hooks/views/detail/types/diff-line/index.ts
Mmods/diff/hooks/views/detail/types/index.ts
Dmods/diff/hooks/views/detail/types/line-kind/index.ts
Dmods/diff/hooks/views/detail/types/line-kind/line-kind.ts
Dmods/diff/hooks/views/detail/types/row/index.ts
Dmods/diff/hooks/views/detail/types/row/row.ts
Dmods/diff/hooks/views/detail/types/run.ts
Dmods/diff/hooks/views/detail/word-backgrounds/index.ts
Dmods/diff/hooks/views/detail/word-backgrounds/word-backgrounds.ts
Mmods/diff/hooks/views/index.ts
Amods/diff/hooks/views/inset-of/index.ts
Amods/diff/hooks/views/inset-of/inset-of.ts
Mmods/diff/hooks/views/kit/kit.ts
Mmods/diff/hooks/views/kit/ui/ui.ts
Mmods/diff/hooks/views/layout/cell-width/cell-width.ts
Mmods/diff/hooks/views/layout/index.ts
Dmods/diff/hooks/views/layout/segment/index.ts
Dmods/diff/hooks/views/layout/segment/segment.ts
Dmods/diff/hooks/views/layout/slice-cells/cells-split/cells-split.ts
Dmods/diff/hooks/views/layout/slice-cells/cells-split/index.ts
Dmods/diff/hooks/views/layout/slice-cells/index.ts
Dmods/diff/hooks/views/layout/slice-cells/slice-cells.ts
Dmods/diff/hooks/views/layout/wrap-segments/index.ts
Dmods/diff/hooks/views/layout/wrap-segments/wrap-segments.ts
Amods/diff/hooks/views/layout/wrapped-lines/index.ts
Amods/diff/hooks/views/layout/wrapped-lines/wrapped-lines.ts
Mmods/diff/hooks/views/pane-actions/pane-actions.ts
Mmods/diff/hooks/views/pane-view.tsx
Amods/diff/hooks/views/sections/close-button/close-button.tsx
Amods/diff/hooks/views/sections/close-button/index.ts
Mmods/diff/hooks/views/sections/controls-view.tsx
Mmods/diff/hooks/views/sections/divider.tsx
Mmods/diff/hooks/views/sections/file-row/file-row.tsx
Mmods/diff/hooks/views/sections/header-view.tsx
Mmods/diff/hooks/views/sections/index.ts
Mmods/diff/hooks/views/sections/todo-bar.tsx
Mmods/diff/hooks/views/turn-pane/turn-pane.tsx
Dmods/diff/hooks/word-diff/change-threshold/change-threshold.ts
Dmods/diff/hooks/word-diff/change-threshold/index.ts
Dmods/diff/hooks/word-diff/diff-parts/diff-parts.ts
Dmods/diff/hooks/word-diff/diff-parts/index.ts
Dmods/diff/hooks/word-diff/index.ts
Dmods/diff/hooks/word-diff/max-lcs-cells/index.ts
Dmods/diff/hooks/word-diff/max-lcs-cells/max-lcs-cells.ts
Dmods/diff/hooks/word-diff/tokens-of/index.ts
Dmods/diff/hooks/word-diff/tokens-of/tokens-of.ts
Dmods/diff/hooks/word-diff/word-diff.ts
Dmods/diff/hooks/word-diff/word-part/index.ts
Dmods/diff/hooks/word-diff/word-part/word-part.ts
Amods/diff/tests/fixtures/builtin-holds.ts
Amods/diff/tests/fixtures/clear.ts
Amods/diff/tests/fixtures/diff.ts
Amods/diff/tests/fixtures/git-hung.ts
Amods/diff/tests/fixtures/git-in.ts
Amods/diff/tests/fixtures/hint-drawn.ts
Amods/diff/tests/fixtures/hint.ts
Amods/diff/tests/fixtures/in-repository.ts
Amods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/keeping.ts
Amods/diff/tests/fixtures/not-a-repository.ts
Amods/diff/tests/fixtures/pane.ts
Amods/diff/tests/fixtures/repository.ts
Amods/diff/tests/fixtures/session.ts
Amods/diff/tests/fixtures/settle-ms.ts
Amods/diff/tests/fixtures/starts-session.ts
Amods/diff/tests/fixtures/text-of.ts
Amods/diff/tests/git.test.ts
Amods/diff/tests/register.test.ts
Mmods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/org-server-names/org-server-names.ts
Amods/sec-default/tests/fixtures/allowlist.ts
Amods/sec-default/tests/fixtures/dropping.ts
Amods/sec-default/tests/fixtures/index.ts
Amods/sec-default/tests/fixtures/listing.ts
Amods/sec-default/tests/fixtures/memory.ts
Amods/sec-default/tests/fixtures/no-allowlist.ts
Amods/sec-default/tests/fixtures/registered-tool-of.ts
Amods/sec-default/tests/fixtures/registering.ts
Amods/sec-default/tests/fixtures/relabeling.ts
Amods/sec-default/tests/fixtures/session.ts
Amods/sec-default/tests/fixtures/signing.ts
Amods/sec-default/tests/fixtures/tools-command.ts
Amods/sec-default/tests/fixtures/tools-registered.ts
Amods/sec-default/tests/fixtures/tools.ts
Amods/sec-default/tests/register.test.ts
Mmods/telemetry/.claude-plugin/plugin.json
Mmods/telemetry/README.md
Mmods/telemetry/hooks/entries/checked-fields.ts
Mmods/telemetry/hooks/entries/checked-mark.ts
Amods/telemetry/hooks/entries/checked-props/checked-props.ts
Amods/telemetry/hooks/entries/checked-props/index.ts
Mmods/telemetry/hooks/entries/checked-value/checked-value.ts
Amods/telemetry/hooks/entries/choice-token/choice-token.ts
Amods/telemetry/hooks/entries/choice-token/index.ts
Amods/telemetry/hooks/entries/core-event-prefix/core-event-prefix.ts
Amods/telemetry/hooks/entries/core-event-prefix/index.ts
Mmods/telemetry/hooks/entries/event-prefix/event-prefix.ts
Mmods/telemetry/hooks/entries/fields-of/fields-of.ts
Mmods/telemetry/hooks/entries/index.ts
Mmods/telemetry/hooks/entries/is-mark-kind/is-mark-kind.ts
Amods/telemetry/hooks/entries/mark/index.ts
Amods/telemetry/hooks/entries/mark/mark.ts
Mmods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts
Mmods/telemetry/hooks/entries/mark-kinds/mark-kinds.ts
Amods/telemetry/hooks/entries/method/index.ts
Amods/telemetry/hooks/entries/method/method.ts
Mmods/telemetry/hooks/entries/refusal/refusal.ts
Mmods/telemetry/hooks/entries/token/token.ts
Mmods/telemetry/hooks/index.ts
Mmods/telemetry/hooks/is-analytics-off/is-analytics-off.ts
Mmods/telemetry/hooks/register.ts
Mmods/telemetry/hooks/telemetry-of/telemetry-of.ts
Dmods/telemetry/hooks/telemetry-types/choice/choice.ts
Dmods/telemetry/hooks/telemetry-types/choice/index.ts
Dmods/telemetry/hooks/telemetry-types/index.ts
Dmods/telemetry/hooks/telemetry-types/log-entry/index.ts
Dmods/telemetry/hooks/telemetry-types/log-entry/log-entry.ts
Dmods/telemetry/hooks/telemetry-types/mark-entry/index.ts
Dmods/telemetry/hooks/telemetry-types/mark-entry/mark-entry.ts
Dmods/telemetry/hooks/telemetry-types/mark-kind/index.ts
Dmods/telemetry/hooks/telemetry-types/mark-kind/mark-kind.ts
Dmods/telemetry/hooks/telemetry-types/method.ts
Dmods/telemetry/hooks/telemetry-types/prop/index.ts
Dmods/telemetry/hooks/telemetry-types/prop/prop.ts
Dmods/telemetry/hooks/telemetry-types/telemetry.ts
Amods/telemetry/tests/fixtures/accepted.ts
Amods/telemetry/tests/fixtures/batch-of.ts
Amods/telemetry/tests/fixtures/bearer.ts
Amods/telemetry/tests/fixtures/first-party-session.ts
Amods/telemetry/tests/fixtures/index.ts
Amods/telemetry/tests/fixtures/record.ts
Amods/telemetry/tests/fixtures/recording.ts
Amods/telemetry/tests/fixtures/survey-answer.ts
Amods/telemetry/tests/register.test.ts
Amods/telemetry/types/index.d.ts
Amods/tsconfig.json
Amods/types/claude-code.d.ts
@@ -1,0 +1,40 @@
1+name: "Mod tests"
2+
3+on:
4+ pull_request:
5+ paths:
6+ - "mods/**"
7+ - ".github/workflows/mod-tests.yml"
8+
9+permissions:
10+ contents: read
11+
12+jobs:
13+ test:
14+ runs-on: ubuntu-latest
15+ steps:
16+ - name: Checkout repository
17+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
18+ with:
19+ persist-credentials: false
20+
21+ - name: Install Claude Code and TypeScript
22+ run: npm install -g @anthropic-ai/claude-code typescript@5.9.3
23+
24+ - name: Typecheck each mod's hooks and tests
25+ run: tsc -p mods/tsconfig.json
26+
27+ - name: Run each mod's tests
28+ run: |
29+ if ! claude plugin test --help 2>/dev/null | grep -q '^Usage: claude plugin test'; then
30+ echo "claude $(claude --version) has no 'plugin test' yet; skipping the test step"
31+ exit 0
32+ fi
33+ status=0
34+ for mod in mods/*/; do
35+ [ -d "$mod/tests" ] || continue
36+ claude plugin test "$mod" || status=1
37+ done
38+ exit $status
39+ env:
40+ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: "1"
@@ -14,10 +14,102 @@
1414 Each folder is a complete plugin: `.claude-plugin/plugin.json`, a
1515 `hooks/hooks.json` naming the module, and TypeScript under `hooks/` typed
1616 against the declarations `/plugin-types` writes (`import type … from
17-'claude-code'`). To read one running from source:
17+'claude-code'`), kept here in `types/`. To read one running from source:
1818
1919 claude --plugin-dir mods/diff
2020
21+## Testing
22+
23+A mod's tests are in its `tests/` folder, and run with
24+
25+ claude plugin test mods/diff
26+
27+A test gets the engine's own `$` and a plugin's `on`. Each call on `$` is one
28+the engine makes, through every hook of the mod loaded as it ships. The hooks
29+the test registers with `on` sit beneath the mod, where the rest of the world
30+would be, and nothing is beneath them: a call they leave unanswered throws,
31+naming its event.
32+
33+A test file is named for what it covers under `hooks/` (`register.test.ts`
34+beside `hooks/register.ts`, `git.test.ts` beside `hooks/git/`), and holds its
35+imports, the tier the mod loads in, and one `describe` titled with that name;
36+what several tests share sits under `tests/fixtures/`, one export a file.
37+
38+```ts
39+import { describe, expect, mock, test, tier } from 'claude-code/testing'
40+
41+tier('builtin')
42+
43+describe('register', () => {
44+ test('outside a git repository /diff says so, opens nothing', async ($, on) => {
45+ const opened: string[] = []
46+ mock.clock(on)
47+ on('session.start', ($, e) => ({ cwd: e.cwd }))
48+ on('command.register', ($, e) => ({ value: { command: e.name } }))
49+ on('process.run', () => ({
50+ value: { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' },
51+ }))
52+ on('ui.open', ($, e, next) => {
53+ opened.push(e.id)
54+ return next(e)
55+ })
56+
57+ await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
58+ const { text } = await $.command.run({
59+ command: 'diff',
60+ args: '',
61+ origin: { kind: 'composer' },
62+ })
63+
64+ expect(text).toContain("isn't in a git repository")
65+ expect(opened).toEqual([])
66+ })
67+})
68+```
69+
70+The kit's `mock` answers the world beneath the mod from memory, noun by noun,
71+each member a plain function over `on` registering hooks where the test calls
72+it: `mock.env(on, variables)` for `$.env`, `mock.store(on, entries)` for
73+`$.store`, and `mock.clock(on)` for `$.clock`, whose `advance(ms)` resolves
74+every wait the mod asked for (`$.clock.sleep`, `after`, `every`) as the clock
75+crosses it. To see what a dispatch does before it answers, start it
76+unawaited, `await clock.settle()`, then look: the clock stays where it was.
77+`$.ui.press({ plugin, key })` presses a `Button` the test rendered, as a
78+click in the terminal does.
79+
80+`tsc -p mods/tsconfig.json` typechecks every mod's hooks and tests against
81+`types/` and each mod's own `types/` contract.
82+
83+## Composing mods: noun contracts
84+
85+A mod that adds a noun to `$` in the `engine.create` fold owns that noun's
86+types, and keeps them in one place: its `types/index.d.ts`, a declaration
87+file with no imports that exports the types the noun is made of, each named
88+for the noun, and declares the noun on `EngineInterface` in `claude-code`
89+(`telemetry/types/index.d.ts` exports `Telemetry`, `TelemetryLogEntry`,
90+`TelemetryMarkEntry` and the rest, and declares `$.telemetry`).
91+
92+- The contract is the only declaration of the noun. The mod's own hooks
93+ import its types from the folder (`import type { Telemetry } from
94+ '../types'`), and the value its `engine.create` hook returns is checked
95+ against `EngineInterface['telemetry']`, so the implementation cannot drift
96+ from what callers read.
97+- A mod that calls another's noun reads the same file and never copies it:
98+ `mods/tsconfig.json` includes `*/types/**/*.d.ts`, so `$.telemetry.log(…)`
99+ in `diff` types against `telemetry`'s contract as it stands, and a helper
100+ that must name one of its types imports it from that folder by path.
101+- A test of a mod that calls another's noun seats a provider for it, an inline
102+ plugin whose `engine.create` hook adds the noun, and answers the calls the
103+ way it answers the engine's: `on('telemetry.log', ($, e) => ({ value:
104+ undefined }))` runs above the provider's own method, its `e` typed by the
105+ contract. With no provider loaded the `$` build refuses the hook, naming the
106+ noun nobody provides.
107+
108+A plugin outside this repository that depends on a mod's noun points its
109+tsconfig `include` at that mod's `types/` folder for now; once the engine
110+writes the contracts of the plugins a session has installed, `/plugin-types`
111+will put them beside `claude-code.d.ts` and the include goes away.
112+
21113 Early access: hooks modules load only where function hooks are enabled, and
22114 the API these mods are written against may change between releases without
23115 notice. They are not listed in this repository's marketplace; the copies that
@@ -7,7 +7,10 @@
77 repository's HEAD so a commit or checkout made elsewhere shows too. The
88 first successful edit of a session opens the pane by itself where the
99 terminal is wide enough (144 columns when the person never chose, 110 when
10-they kept it open before; a person who closed it is left alone). A file's
10+they kept it open before; a person who closed it is left alone). On a
11+terminal under 110 columns `/diff` answers with the built-in's line asking
12+for a wider terminal and opens nothing; a pane opened wider that the surface
13+then seats inline shows only that line. A file's
1114 ask button arms that file: its hunks ride the next prompt as context, once.
1215
1316 The pane compares the working tree against HEAD, split at the session's
@@ -1,0 +1,5 @@
1+/**
2+ * The line an ask ends with when its diff was cut to fit the prompt.
3+ */
4+export const ASK_CUT_NOTE =
5+ '(The rest of this diff was cut: it did not fit in the prompt.)'
@@ -1,0 +1,3 @@
1+export * from './ask-cut-note.js'
2+
3+export * as default from '.'
@@ -1,0 +1,36 @@
1+import { ASK_CUT_NOTE } from './ask-cut-note'
2+
3+/**
4+ * An armed ask's text as it fits in the room a prompt's context has left:
5+ * whole when it fits, else its first lines and ASK_CUT_NOTE.
6+ *
7+ * @param text the armed ask's text
8+ * @param room the characters the context has left
9+ * @returns the text to attach, or undefined when not even its first line fits
10+ */
11+export function fittedAskTextOf(
12+ text: string,
13+ room: number,
14+): string | undefined {
15+ if (text.length <= room) {
16+ return text
17+ }
18+
19+ const kept: string[] = []
20+ let used = ASK_CUT_NOTE.length
21+
22+ for (const line of text.split('\n')) {
23+ const cost = line.length + 1
24+
25+ if (used + cost > room) {
26+ break
27+ }
28+
29+ kept.push(line)
30+ used += cost
31+ }
32+
33+ const hasBody = kept.length > 1
34+
35+ return hasBody ? `${kept.join('\n')}\n${ASK_CUT_NOTE}` : undefined
36+}
@@ -1,4 +1,6 @@
11 export * from './armed-ask'
22 export * from './armed-ask-of.js'
3+export * from './ask-cut-note'
4+export * from './fitted-ask-text-of.js'
35
46 export * as default from '.'
@@ -2,10 +2,11 @@
22 import type { Backend, BackendHost, BackendProbe } from './types'
33
44 /**
5- * The backend holding the session's directory: the first of `probes` that
6- * answers one, else git when a git working tree holds it, else none. The
7- * probes go first so a probe may claim a working copy by its own rule
8- * (the nearer of two markers, say); a probe answers null wherever git
5+ * The backend holding the session's directory: another system's when its
6+ * probe answers first, else git when a git working tree holds it, else none.
7+ *
8+ * Other systems are asked first so one may claim a working copy by its own
9+ * rule (the nearer of two markers, say); each answers null wherever git
910 * should govern.
1011 *
1112 * @param host the bound host's runner and probes
@@ -1,15 +1,12 @@
11 import type { BackendProbe } from './types'
22
33 /**
4- * Where a host adds backends to the plugin: probes for version-control systems
5- * other than git, consulted in insertion order before git (backendOf); a
6- * probe answers null wherever git should govern. Empty as shipped, so the
7- * plugin reads git alone. A host
8- * that compiles the module in may add a probe once, before the plugin
9- * registers; the module runs natively, so that probe is what
10- * `session.start` sees. The module contract offers no other channel: the
11- * options `register` receives are the manifest's userConfig, and the
12- * shipped scan requires `register` itself to be a plain exported function.
4+ * Where a host adds backends: probes for version-control systems other than
5+ * git, asked in insertion order before git (backendOf). Empty as shipped.
6+ *
7+ * A host compiling the module in adds a probe once, before the plugin
8+ * registers: the module runs natively, and there is no other channel (the
9+ * options are the manifest's userConfig; the scan wants a plain function).
1310 */
1411 // module-state-allow: module-evaluation code wiring — a compiling host adds its probe functions (code references, not runtime input) once before registration and never removes or rebinds them; tests pass probes to backendOf directly; a per-Host copy would hold the same functions
1512 export const INSTALLED_BACKEND_PROBES = new Set<BackendProbe>()
@@ -2,9 +2,8 @@
22 import type { BackendWords } from '../backend-words'
33
44 /**
5- * The version-control system behind the pane, pinned once per session:
6- * where its working tree is, which comparisons it offers, how it words
7- * them, and the three reads the pane makes of it.
5+ * The version-control system behind the pane, pinned once per session: its
6+ * working tree, what it compares, how it words them, and the pane's reads.
87 *
98 * Git is the plugin's own backend; a host may install probes for others,
109 * consulted where no git working tree holds the directory (backendOf).
@@ -16,7 +15,7 @@
1615 repository: Pick<Git.Repository, 'toplevel'>
1716
1817 /**
19- * The base modes the picker offers, in picker order.
18+ * What the picker offers to compare against, in its order.
2019 */
2120 baseModes: readonly Git.BaseMode[]
2221
@@ -1,19 +1,20 @@
11 import type Git from '../../../git'
2-import type Host from '../../../host'
2+import type { Host } from '../../../host'
33
44 /**
5- * What pinning a backend takes from the bound host: the raw process
6- * runner (each backend names its own program, argv lead, environment and
7- * timeout), the file probes, the engine's clock, the session's start as it
8- * stands at each fetch (a later `session.start` moves it), and the
9- * branch-base listener.
5+ * What pinning a backend takes from the bound host: its raw process runner,
6+ * file probes, clock, session start and branch-base listener.
7+ *
8+ * Each backend names its own program, argv lead, environment and timeout
9+ * to the runner; the session's start is read as it stands at each fetch
10+ * (a later `session.start` moves it).
1011 */
11-export type BackendHost = Pick<Host.Host, 'run' | 'readFile'> &
12+export type BackendHost = Pick<Host, 'run' | 'readFile'> &
1213 Pick<Git.GitDeps, 'mtimeOf' | 'entryKindsOf' | 'onBranchBase'> & {
1314 /**
1415 * The engine's clock, in milliseconds (`$.clock.now`).
1516 */
16- nowMs: () => number
17+ nowMs: () => Promise<number>
1718
1819 /**
1920 * When the current session began, read at each fetch.
@@ -3,7 +3,8 @@
33
44 /**
55 * Probes the session's directory for one backend's working tree: the
6- * backend when it holds the directory, null when it does not or when git
7- * should govern it instead (Git.gitBackendOf is git's).
6+ * backend when it holds the directory, else null.
7+ *
8+ * Null too where git should govern it instead (Git.gitBackendOf is git's).
89 */
910 export type BackendProbe = (host: BackendHost) => Promise<Backend | null>
@@ -1,7 +1,9 @@
11 /**
2- * The words the pane draws that name the backend: what uncommitted mode
3- * compares against, the command whose failure the empty state names, the
4- * tool that lists untracked files, and the note under an untracked row.
2+ * The words the pane draws that name the backend.
3+ *
4+ * What uncommitted mode compares against, the command whose failure the
5+ * empty state names, the tool that lists untracked files, and the note
6+ * under an untracked row.
57 */
68 export type BackendWords = {
79 /**
@@ -15,6 +15,7 @@
1515 export function isGeneratedFile(path: string) {
1616 const name = baseNameOf(path).toLowerCase()
1717 const rooted = rootedPathOf(path)
18+
1819 const hasGeneratedSuffix = GENERATED_SUFFIXES.some(suffix =>
1920 name.endsWith(suffix),
2021 )
@@ -1,0 +1,33 @@
1+import Probes from '../probes'
2+import Tiers from '../tiers'
3+import type Types from '../types'
4+
5+/**
6+ * One fetch of the session's pinned repository, as the built-in panel
7+ * fetches; every git child names it so paths stay root-relative.
8+ *
9+ * Mid-merge, mid-rebase, mid-cherry-pick or mid-revert, or when git cannot
10+ * answer, it is unavailable and the caller keeps what it had.
11+ *
12+ * @param deps the host: pinned git, the repository, the file probes
13+ * @param mode what to compare against
14+ * @returns the outcome
15+ */
16+export async function fetchDiff(
17+ deps: Types.GitDeps,
18+ mode: Types.BaseMode,
19+): Promise<Types.FetchOutcome> {
20+ const isBusy = await Probes.isTransient(
21+ deps.entryKindsOf,
22+ deps.repository.gitDir,
23+ )
24+
25+ if (isBusy) {
26+ return { kind: 'unavailable' }
27+ }
28+
29+ const context = Tiers.fetchContextOf(deps, mode)
30+ const isBranch = mode === 'branch'
31+
32+ return isBranch ? Tiers.branchTier(context) : Tiers.workingTreeTier(context)
33+}
@@ -1,0 +1,3 @@
1+export * from './fetch-diff.js'
2+
3+export * as default from '.'
Dmods/diff/hooks/git/fetch-diff.ts+0 -33
@@ -1,33 +1,0 @@
1-import Probes from './probes'
2-import Tiers from './tiers'
3-import type Types from './types'
4-
5-/**
6- * One fetch of the session's pinned repository, as the built-in panel
7- * fetches; every git child names it so paths stay root-relative.
8- *
9- * Mid-merge, mid-rebase, mid-cherry-pick or mid-revert, or when git cannot
10- * answer, it is unavailable and the caller keeps what it had.
11- *
12- * @param deps the host: pinned git, the repository, the file probes
13- * @param mode what to compare against
14- * @returns the outcome
15- */
16-export async function fetchDiff(
17- deps: Types.GitDeps,
18- mode: Types.BaseMode,
19-): Promise<Types.FetchOutcome> {
20- const isBusy = await Probes.isTransient(
21- deps.entryKindsOf,
22- deps.repository.gitDir,
23- )
24-
25- if (isBusy) {
26- return { kind: 'unavailable' }
27- }
28-
29- const context = Tiers.fetchContextOf(deps, mode)
30- const isBranch = mode === 'branch'
31-
32- return isBranch ? Tiers.branchTier(context) : Tiers.workingTreeTier(context)
33-}
@@ -1,0 +1,41 @@
1+import Argv from '../argv'
2+import { EMPTY_FILE_HUNKS } from '../empty-file-hunks'
3+import Parse from '../parse'
4+import type Types from '../types'
5+
6+/**
7+ * One file's hunks against the base its row was read against, so body and
8+ * counts agree, by literal pathspec, as the built-in panel reads hunks.
9+ *
10+ * A rename passes both its paths so git pairs them; an untracked or binary
11+ * row, or on an unborn HEAD a file edited after staging, has no honest
12+ * body and answers no hunks.
13+ *
14+ * @param run runs git against the pinned repository
15+ * @param data the fetch the row belongs to
16+ * @param file the row
17+ * @returns the parsed body, or null when git failed
18+ */
19+export async function fetchFileHunks(
20+ run: Types.GitRun,
21+ data: Types.DiffData,
22+ file: Types.FileStat,
23+): Promise<Types.FileHunks | null> {
24+ const hasNoBody =
25+ file.isUntracked || file.isBinary || data.stalePaths.includes(file.path)
26+
27+ if (hasNoBody) {
28+ return EMPTY_FILE_HUNKS
29+ }
30+
31+ const { exitCode, stdout } = await run([
32+ '--literal-pathspecs',
33+ ...Argv.DIFF_LEADING_ARGS,
34+ data.baseRef,
35+ '--',
36+ ...(file.renamedFrom === null ? [] : [file.renamedFrom]),
37+ file.path,
38+ ])
39+
40+ return exitCode === 0 ? Parse.parseFileDiff(stdout) : null
41+}
@@ -1,0 +1,3 @@
1+export * from './fetch-file-hunks.js'
2+
3+export * as default from '.'
Dmods/diff/hooks/git/fetch-file-hunks.ts+0 -41
@@ -1,41 +1,0 @@
1-import Argv from './argv'
2-import { EMPTY_FILE_HUNKS } from './empty-file-hunks'
3-import GitParse from './parse'
4-import type Types from './types'
5-
6-/**
7- * One file's hunks against the base its row was read against, so body and
8- * counts agree, by literal pathspec, as the built-in panel reads hunks.
9- *
10- * A rename passes both its paths so git pairs them; an untracked or binary
11- * row, or on an unborn HEAD a file edited after staging, has no honest
12- * body and answers no hunks.
13- *
14- * @param run runs git against the pinned repository
15- * @param data the fetch the row belongs to
16- * @param file the row
17- * @returns the parsed body, or null when git failed
18- */
19-export async function fetchFileHunks(
20- run: Types.GitRun,
21- data: Types.DiffData,
22- file: Types.FileStat,
23-): Promise<Types.FileHunks | null> {
24- const hasNoBody =
25- file.isUntracked || file.isBinary || data.stalePaths.includes(file.path)
26-
27- if (hasNoBody) {
28- return EMPTY_FILE_HUNKS
29- }
30-
31- const { exitCode, stdout } = await run([
32- '--literal-pathspecs',
33- ...Argv.DIFF_LEADING_ARGS,
34- data.baseRef,
35- '--',
36- ...(file.renamedFrom === null ? [] : [file.renamedFrom]),
37- file.path,
38- ])
39-
40- return exitCode === 0 ? GitParse.parseFileDiff(stdout) : null
41-}
@@ -2,8 +2,8 @@
22 import Limits from '../limits'
33 import Argv from './argv'
44 import { FAILED_RUN } from './failed-run'
5-import { fetchDiff } from './fetch-diff.js'
6-import { fetchFileHunks } from './fetch-file-hunks.js'
5+import { fetchDiff } from './fetch-diff'
6+import { fetchFileHunks } from './fetch-file-hunks'
77 import { GIT_BASE_MODES } from './git-base-modes'
88 import { GIT_WORDS } from './git-words'
99 import Probes from './probes'
@@ -11,10 +11,11 @@
1111
1212 /**
1313 * The git backend over the session's directory: the repository read with
14- * one `rev-parse` in that directory, then every child pinned to it
15- * (`--git-dir`, `--work-tree`, cwd the top) under the C locale and the
16- * fetch's timeout (gitDiff.ts execPinnedGit).
14+ * one `rev-parse` in that directory, then every child pinned to it.
1715 *
16+ * Each child runs with `--git-dir`, `--work-tree` and cwd the top, under
17+ * the C locale and the fetch's timeout (the built-in's execPinnedGit).
18+ *
1819 * @param host the bound host's runner and probes
1920 * @returns the backend, or null outside a git working tree
2021 */
@@ -45,6 +46,7 @@
4546 }
4647
4748 const run = runOf(repository)
49+
4850 const depsOf = (): Types.GitDeps => ({
4951 run,
5052 repository,
@@ -1,8 +1,10 @@
11 import type Backend from '../../backend'
22
33 /**
4- * The pane's git words: compared with HEAD, read by `git diff`, untracked
5- * files listed by git and staged with `git add ':/…'` (DiffDetailView's).
4+ * The pane's git words, unquoted as DiffDetailView words them.
5+ *
6+ * Compared with HEAD, read by `git diff`, untracked files listed by git and
7+ * staged with `git add :/…`.
68 */
79 export const GIT_WORDS: Backend.BackendWords = Object.freeze({
810 base: 'HEAD',
@@ -11,7 +13,7 @@
1113 untrackedNoteOf: (path: string, isPasteable: boolean) => [
1214 'New file not yet staged.',
1315 isPasteable
14- ? `Run \`git add ':/${path}'\` to see line counts.`
16+ ? `Run \`git add :/${path}\` to see line counts.`
1517 : 'Stage it with git add to see line counts.',
1618 ],
1719 })
@@ -2,8 +2,8 @@
22 export * from './cut-hunks'
33 export * from './empty-file-hunks'
44 export * from './failed-run'
5-export * from './fetch-diff.js'
6-export * from './fetch-file-hunks.js'
5+export * from './fetch-diff'
6+export * from './fetch-file-hunks'
77 export * from './git-backend-of.js'
88 export * from './git-base-modes'
99 export * from './git-words'
@@ -3,12 +3,11 @@
33 import { MOST_UTF8_BYTES } from './most-utf8-bytes'
44
55 /**
6- * Whether a listing arrived whole: every record terminated (NUL for git's
7- * `-z` listings, a newline for line records) and the text short of the
8- * host's output cap, so no record was cut or lost.
6+ * Whether a listing arrived whole, so no record was cut or lost.
97 *
10- * A cut inside a multi-byte character costs the decoded text up to
11- * MOST_UTF8_BYTES, hence the margin under the cap.
8+ * Every record is terminated (NUL for `-z` listings, a newline for line
9+ * records) and the text is short of the host's output cap by MOST_UTF8_BYTES,
10+ * what a cut inside a multi-byte character can cost the decoded text.
1211 *
1312 * @param stdout the listing as `$.process.run` returned it
1413 * @param terminator what ends every record; NUL when absent
@@ -22,6 +22,7 @@
2222 }
2323
2424 const rows = stdout.split('\n')
25+
2526 const headerRows = rows.flatMap((row, at) =>
2627 HUNK_HEADER.test(row) ? [at] : [],
2728 )
@@ -26,11 +26,13 @@
2626 const [addedField, removedField, ...pathFields] = (records[at] ?? '').split(
2727 '\t',
2828 )
29+
2930 const inline = pathFields.join('\t')
3031 const isRename = pathFields.length === 1 && inline === ''
3132 const renamedFrom = isRename ? (records[at + 1] ?? null) : null
3233 const path = isRename ? (records[at + 2] ?? '') : inline
3334 at += isRename ? RENAME_RECORDS : 1
35+
3436 const isRow =
3537 addedField !== undefined && removedField !== undefined && path !== ''
3638
@@ -41,6 +43,7 @@
4143 const isBinary = addedField === '-' || removedField === '-'
4244 const added = isBinary ? 0 : Number(addedField) || 0
4345 const removed = isBinary ? 0 : Number(removedField) || 0
46+
4447 stats.filesCount += 1
4548 stats.linesAdded += added
4649 stats.linesRemoved += removed
@@ -23,6 +23,7 @@
2323 currentBranchOf(run),
2424 defaultBranchOf(run),
2525 ])
26+
2627 const isUnrelated = branch === 'HEAD' || defaultBranch.startsWith('-')
2728
2829 if (isUnrelated) {
@@ -37,6 +38,7 @@
3738 run,
3839 defaultBranch,
3940 )
41+
4042 const [first, second] = candidates
4143
4244 if (first === undefined) {
@@ -57,6 +59,7 @@
5759 second,
5860 ])
5961 ).exitCode === 0
62+
6063 const mergeBase = isLocalBaseNewer ? second : first
6164 const head = await run([Argv.NO_OPTIONAL_LOCKS, 'rev-parse', 'HEAD'])
6265
@@ -14,6 +14,7 @@
1414 '--abbrev-ref',
1515 'HEAD',
1616 ])
17+
1718 const name = stdout.trim()
1819
1920 return exitCode === 0 && name !== '' ? name : 'HEAD'
@@ -9,18 +9,19 @@
99 * path is never listed, descended or stat'ed: session work (the engine
1010 * lstats); a path the listing budget never reached is the caller's to place.
1111 *
12- * @param probe the fetch's stamp probe over the working tree's top
13- * @param sessionStartMs when the session began
12+ * @param context the fetch's stamp probe over the working tree's top, and
13+ * when the session began
1414 * @param paths repository-relative paths, `/`-separated as git prints them
1515 * @returns each path's Dating
1616 */
1717 export async function datingsOf(
18- probe: Types.StampProbe,
19- sessionStartMs: number,
18+ context: Types.DatingContext,
2019 paths: readonly string[],
2120 ): Promise<ReadonlyMap<string, Types.Dating>> {
21+ const { sessionStartMs } = context.deps
22+
2223 const stamps: readonly Types.Stamp[] = await Promise.all(
23- paths.map(path => stampIfFile(probe, path)),
24+ paths.map(path => stampIfFile(context.stamps, path)),
2425 )
2526
2627 function datingOf(stamp: Types.Stamp): Types.Dating {
@@ -22,12 +22,14 @@
2222 `refs/remotes/origin/${branch}`,
2323 ])
2424 ).exitCode === 0
25+
2526 const symref = await run([
2627 Argv.NO_OPTIONAL_LOCKS,
2728 'symbolic-ref',
2829 '--short',
2930 'refs/remotes/origin/HEAD',
3031 ])
32+
3133 const named =
3234 symref.exitCode === 0 ? symref.stdout.trim().replace(/^origin\//, '') : ''
3335
@@ -26,7 +26,9 @@
2626 repository.gitDir,
2727 Limits.MAX_LISTED_DIRECTORIES,
2828 )
29+
2930 const isShared = repository.commonDir === repository.gitDir
31+
3032 const common = isShared
3133 ? own
3234 : stampProbeOf(deps, repository.commonDir, Limits.MAX_LISTED_DIRECTORIES)
@@ -22,7 +22,7 @@
2222 export * from './transient-state-files'
2323 export * from './unstaged-numstat.js'
2424 export * from './untracked-files'
25-export * from './untracked-rows-of'
2625 export * from './untracked-place'
26+export * from './untracked-rows-of'
2727
2828 export * as default from '.'
@@ -33,6 +33,7 @@
3333
3434 const listed =
3535 held ?? entryKindsOf(directory === '' ? root : `${root}/${directory}`)
36+
3637 listings.set(directory, listed)
3738
3839 return listed
@@ -27,6 +27,7 @@
2727 '--quiet',
2828 ref,
2929 ])
30+
3031 const isPresentOrUnknown = exitCode === 0 || exitCode === -1
3132
3233 if (isPresentOrUnknown) {
@@ -17,6 +17,7 @@
1717 unstaged: Types.NumstatResult,
1818 ): Types.NumstatResult {
1919 const edits = new Map(unstaged.files.map(file => [file.path, file]))
20+
2021 const files = staged.files.map(file => {
2122 const edit = edits.get(file.path)
2223
@@ -25,16 +26,19 @@
2526 }
2627
2728 const isBinary = file.isBinary || edit.isBinary
29+
2830 const added = isBinary
2931 ? 0
3032 : Math.max(0, file.added + edit.added - edit.removed)
3133
3234 return { ...file, added, removed: 0, isBinary }
3335 })
36+
3437 const linesAdded =
3538 staged.stats.linesAdded +
3639 files.reduce((sum, file) => sum + file.added, 0) -
3740 staged.files.reduce((sum, file) => sum + file.added, 0)
41+
3842 const stats = { ...staged.stats, linesAdded }
3943
4044 return { stats, files }
@@ -25,8 +25,10 @@
2525 '--git-dir',
2626 '--git-common-dir',
2727 ])
28+
2829 const lines = stdout.split('\n').filter(line => line !== '')
2930 const [toplevel = '', gitDir = '', commonDir = ''] = lines
31+
3032 const isResolved =
3133 exitCode === 0 &&
3234 lines.length === REPOSITORY_LINES &&
@@ -1,6 +1,6 @@
11 import Limits from '../../limits'
22 import Argv from '../argv'
3-import GitParse from '../parse'
3+import Parse from '../parse'
44 import type Types from '../types'
55
66 /**
@@ -21,8 +21,10 @@
2121 const run = context.run
2222
2323 const shortstat = await run([...Argv.DIFF_LEADING_ARGS, base, '--shortstat'])
24+
2425 const totals =
25- shortstat.exitCode === 0 ? GitParse.parseShortstat(shortstat.stdout) : null
26+ shortstat.exitCode === 0 ? Parse.parseShortstat(shortstat.stdout) : null
27+
2628 const isPastDetails =
2729 totals !== null && totals.filesCount > Limits.MAX_FILES_FOR_DETAILS
2830
@@ -36,7 +38,8 @@
3638 '--numstat',
3739 '-z',
3840 ])
39- const isRead = GitParse.isWholeAnswer(numstat)
4041
41- return isRead ? GitParse.parseNumstat(numstat.stdout, Limits.MAX_FILES) : null
42+ const isRead = Parse.isWholeAnswer(numstat)
43+
44+ return isRead ? Parse.parseNumstat(numstat.stdout, Limits.MAX_FILES) : null
4245 }
@@ -17,8 +17,7 @@
1717 files: readonly Types.FileStat[],
1818 ): Promise<readonly Types.FileStat[]> {
1919 const datings = await datingsOf(
20- context.stamps,
21- context.deps.sessionStartMs,
20+ context,
2221 files.map(file => file.path),
2322 )
2423
@@ -1,5 +1,5 @@
11 import Argv from '../argv'
2-import GitParse from '../parse'
2+import Parse from '../parse'
33 import type Types from '../types'
44
55 /**
@@ -15,9 +15,9 @@
1515 run: Types.GitRun,
1616 ): Promise<Types.NumstatResult | null> {
1717 const numstat = await run([...Argv.DIFF_LEADING_ARGS, '--numstat', '-z'])
18- const isRead = GitParse.isWholeAnswer(numstat)
18+ const isRead = Parse.isWholeAnswer(numstat)
1919
2020 return isRead
21- ? GitParse.parseNumstat(numstat.stdout, Number.POSITIVE_INFINITY)
21+ ? Parse.parseNumstat(numstat.stdout, Number.POSITIVE_INFINITY)
2222 : null
2323 }
@@ -1,5 +1,5 @@
11 import Argv from '../../argv'
2-import GitParse from '../../parse'
2+import Parse from '../../parse'
33 import type Types from '../../types'
44 import type { UntrackedPlace } from '../untracked-place'
55 import { untrackedRowsOf } from '../untracked-rows-of'
@@ -30,11 +30,11 @@
3030 '--full-name',
3131 ])
3232
33- if (!GitParse.isWholeAnswer(listing)) {
34- return null
35- }
36-
37- const paths = listing.stdout.split('\0').filter(path => path !== '')
38-
39- return untrackedRowsOf(context, paths, place)
33+ return Parse.isWholeAnswer(listing)
34+ ? untrackedRowsOf(
35+ context,
36+ listing.stdout.split('\0').filter(path => path !== ''),
37+ place,
38+ )
39+ : null
4040 }
@@ -22,16 +22,15 @@
2222 place: UntrackedPlace,
2323 ): Promise<readonly Types.FileStat[]> {
2424 const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES)
25- const datings = await datingsOf(
26- context.stamps,
27- context.deps.sessionStartMs,
28- probedPaths,
29- )
25+ const datings = await datingsOf(context, probedPaths)
26+
3027 const probed = probedPaths.map(path => ({
3128 path,
3229 isPreSession: datings.get(path) !== 'session',
3330 }))
31+
3432 const isWithPreSession = place.scope === 'with-pre-session'
33+
3534 const earlier = isWithPreSession
3635 ? [
3736 ...probed.filter(file => file.isPreSession),
@@ -40,6 +39,7 @@
4039 .map(path => ({ path, isPreSession: true })),
4140 ]
4241 : []
42+
4343 const ordered = [...probed.filter(file => !file.isPreSession), ...earlier]
4444
4545 return ordered.slice(0, place.slots).map(file => ({
@@ -31,6 +31,7 @@
3131 }
3232
3333 const isOverlaid = staged.files.length > 0
34+
3435 const unstaged = isOverlaid
3536 ? await Probes.unstagedNumstat(run)
3637 : EMPTY_NUMSTAT
@@ -3,10 +3,10 @@
33
44 /**
55 * A tier's tracked rows and totals with a listed set of untracked rows
6- * after them (gitDiff.ts mergeSessionUntrackedFiles), or flagged withheld
7- * when the lister gave no whole listing.
6+ * after them, or flagged withheld when the lister gave no whole listing.
87 *
9- * A path a tracked row already holds keeps that row and counts once.
8+ * As the built-in's mergeSessionUntrackedFiles merges them, a path a
9+ * tracked row already holds keeps that row and counts once.
1010 *
1111 * @param result the tier's tracked rows and totals
1212 * @param untracked the untracked rows, or null when their listing was withheld
@@ -22,15 +22,13 @@
2222 result: Types.NumstatResult,
2323 scope: Types.UntrackedScope,
2424 ): Promise<Types.MergedResult> {
25- if (isPastDetails(result)) {
26- return mergedResultOf(result, { isUntrackedWithheld: false })
27- }
28-
29- return mergedWithUntracked(
30- result,
31- await Probes.untrackedFiles(context, {
32- slots: Limits.MAX_FILES - result.files.length,
33- scope,
34- }),
35- )
25+ return isPastDetails(result)
26+ ? mergedResultOf(result, { isUntrackedWithheld: false })
27+ : mergedWithUntracked(
28+ result,
29+ await Probes.untrackedFiles(context, {
30+ slots: Limits.MAX_FILES - result.files.length,
31+ scope,
32+ }),
33+ )
3634 }
@@ -26,9 +26,11 @@
2626 }
2727
2828 const isSession = context.mode === 'session'
29+
2930 const files = isSession
3031 ? await Probes.tagPreSession(context, workingTree.files)
3132 : workingTree.files
33+
3234 const data = diffDataOf(
3335 context,
3436 await withUntracked(
@@ -6,8 +6,7 @@
66
77 /**
88 * One settled fetch: the rows, the totals, what they compare, and what a
9- * file's hunks must be read against so both agree, over the working tree's
10- * top (every backend has one; git's other directories stay its own).
9+ * file's hunks are read against so both agree, over the working tree's top.
1110 *
1211 * `isUnborn`: a repository before its first commit (rows are the staged
1312 * and new files, `baseRef` is `--cached`); `stalePaths`: files whose staged
@@ -1,8 +1,9 @@
11 /**
22 * What a fetched diff compares: the working tree against its checked-out
3- * commit (`base` names it as the pane should: `HEAD`, or a short sha where
4- * the backend has no such word), or against the merge-base with the default
5- * branch (`baseRef` the ref git was handed).
3+ * commit, or against the merge-base with the default branch.
4+ *
5+ * `base` names the commit as the pane should (`HEAD`, or a short sha where
6+ * the backend has no such word); `baseRef` is the ref git was handed.
67 */
78 export type DiffSource =
89 | { kind: 'working-tree'; base: string }
@@ -19,7 +19,7 @@
1919 /**
2020 * `$.clock.now`.
2121 */
22- now: () => number
22+ now: () => Promise<number>
2323
2424 /**
2525 * `$.clock.after`.
@@ -105,6 +105,11 @@
105105 registerCommand: (spec: CommandSpec) => Promise<unknown>
106106
107107 /**
108+ * Which session the pane-shown row is latched to, as `$.session.id` says.
109+ */
110+ sessionId: () => Promise<string>
111+
112+ /**
108113 * `$.telemetry.mark`; rejects where the telemetry built-in is absent.
109114 */
110115 mark: EngineInterface['telemetry']['mark']
@@ -21,6 +21,5 @@
2121 export * from './tools'
2222 export * from './turns'
2323 export * from './views'
24-export * from './word-diff'
2524
2625 export * as default from '.'
@@ -1,5 +1,6 @@
11 export * from './auto-open-min-columns.js'
22 export * from './open-min-columns.js'
3+export * from './pane-right-pad-columns.js'
34 export * from './widest-bucket-columns.js'
45
56 export * as default from '.'
@@ -1,5 +1,8 @@
11 /**
2- * The terminal width from which the first edit opens the pane when the person
3- * kept it open before (the built-in sidebar's own minimum).
2+ * The terminal width from which `/diff` opens the pane (the built-in
3+ * sidebar's own minimum).
4+ *
5+ * The first edit opens it from this width too, when the person kept it
6+ * open before.
47 */
58 export const OPEN_MIN_COLUMNS = 110
@@ -1,0 +1,5 @@
1+/**
2+ * The blank column the docked pane keeps at its right edge, as the built-in
3+ * sidebar's body does (its `paddingX`): rows wrap one column short of it.
4+ */
5+export const PANE_RIGHT_PAD_COLUMNS = 1
@@ -1,0 +1,5 @@
1+/**
2+ * The most of a child's stdout `$.process.run` hands back, in bytes
3+ * (process-host OUTPUT_LIMIT); a listing that long was cut, silently.
4+ */
5+export const HOST_OUTPUT_CAP_BYTES = 4 * 1024 * 1024
@@ -1,0 +1,4 @@
1+export * from './host-output-cap-bytes.js'
2+export * from './max-diff-bytes.js'
3+
4+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * A file whose unified diff text is longer than this is a "large file": its
3+ * hunks are not parsed (the built-in panel's size cap).
4+ */
5+export const MAX_DIFF_BYTES = 1_000_000
Dmods/diff/hooks/limits/sizes/host-output-cap-bytes.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The most of a child's stdout `$.process.run` hands back, in bytes
3- * (process-host OUTPUT_LIMIT); a listing that long was cut, silently.
4- */
5-export const HOST_OUTPUT_CAP_BYTES = 4 * 1024 * 1024
@@ -1,9 +1,10 @@
1-export * from './host-output-cap-bytes.js'
2-export * from './max-diff-bytes.js'
1+export * from './bytes'
32 export * from './max-files.js'
43 export * from './max-files-for-details.js'
54 export * from './max-lines-per-file.js'
65 export * from './max-listed-directories.js'
76 export * from './max-untracked-probes.js'
7+export * from './pane-top-pad-rows.js'
8+export * from './prompt-context-max-chars.js'
89
910 export * as default from '.'
Dmods/diff/hooks/limits/sizes/max-diff-bytes.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * A file whose unified diff text is longer than this is a "large file": its
3- * hunks are not parsed (the built-in panel's size cap).
4- */
5-export const MAX_DIFF_BYTES = 1_000_000
@@ -1,0 +1,5 @@
1+/**
2+ * The blank row the docked pane keeps above its header, as the built-in
3+ * sidebar's header does (its `paddingY`).
4+ */
5+export const PANE_TOP_PAD_ROWS = 1
@@ -1,0 +1,5 @@
1+/**
2+ * The most characters a prompt's context carries, its entries together; the
3+ * engine refuses a context past it, so an ask is fitted to what is left.
4+ */
5+export const PROMPT_CONTEXT_MAX_CHARS = 32_000
@@ -1,4 +1,4 @@
11 /**
2- * The pane header's text.
2+ * The pane's title: its tab while another pane is open too.
33 */
44 export const PANE_TITLE = 'Diff'
@@ -1,0 +1,9 @@
1+/**
2+ * `/diff`'s answer when git did not say whether this is a repository.
3+ *
4+ * It timed out: the directory is unknown, not outside one. A git that
5+ * cannot start has answered, and gets the not-a-repository text.
6+ */
7+export const GIT_UNANSWERED_TEXT =
8+ "The diff panel couldn't read git state — git didn't answer; run /diff " +
9+ 'again'
@@ -1,6 +1,8 @@
11 export * from './builtin-holds-pattern.js'
2+export * from './git-unanswered-text.js'
23 export * from './not-in-repository-text.js'
34 export * from './register-failed-text-of.js'
4-export * from './untracked-withheld-text.js'
5+export * from './resize-terminal-text.js'
6+export * from './untracked-withheld-text-of.js'
57
68 export * as default from '.'
@@ -1,6 +1,7 @@
11 /**
2- * `/diff`'s answer outside a git repository, worded as the built-in words it.
2+ * `/diff`'s answer outside a git repository, worded as the built-in words it
3+ * but for its apostrophe, which is ASCII.
34 */
45 export const NOT_IN_REPOSITORY_TEXT =
5- 'The diff panel shows git changes — the current directory isn’t in a ' +
6+ "The diff panel shows git changes — the current directory isn't in a " +
67 'git repository'
@@ -1,0 +1,9 @@
1+/**
2+ * `/diff`'s answer on a terminal narrower than the built-in panel shows
3+ * on, worded as the built-in words it.
4+ *
5+ * Also the pane's one line where the surface seats it inline (no room
6+ * beside the transcript).
7+ */
8+export const RESIZE_TERMINAL_TEXT =
9+ 'Resize your terminal to at least 110 columns to show the diff panel'
@@ -1,0 +1,14 @@
1+import type Backend from '../../backend'
2+
3+/**
4+ * The pane's word for a fetch whose untracked listing the backend did not
5+ * give whole: the tracked rows stand, the new files are not counted.
6+ *
7+ * @param words the backend's words, naming the program that lists them
8+ * @returns the note
9+ */
10+export const untrackedWithheldTextOf = (
11+ words: Pick<Backend.BackendWords, 'lister'>,
12+) =>
13+ `Untracked files unavailable (${words.lister} could not list them); ` +
14+ 'not counted'
Dmods/diff/hooks/names/texts/untracked-withheld-text.ts+0 -9
@@ -1,9 +1,0 @@
1-/**
2- * The pane's word for a fetch whose untracked listing the backend did not
3- * give whole: the tracked rows stand, the new files are not counted.
4- *
5- * @param lister the program that lists untracked files (BackendWords)
6- * @returns the note
7- */
8-export const untrackedWithheldTextOf = (lister: string) =>
9- `Untracked files unavailable (${lister} could not list them); not counted`
@@ -1,6 +1,5 @@
1-import type Backend from '../backend'
2-import type Git from '../git'
31 import { modeLabelOf } from './mode-label-of'
2+import type { PaneModel } from './pane-model'
43
54 /**
65 * The dim line under the header naming what the diff compares against
@@ -10,18 +9,17 @@
109 * with an ellipsis while its fetch is pending; on an unborn HEAD with
1110 * rows, what the rows are instead.
1211 *
13- * @param requested the mode the person picked
14- * @param data the last good fetch
12+ * @param model the mode the person picked, the last good fetch, and the
13+ * backend's words
1514 * @param filesCount the header's session file count
16- * @param words the backend's words
1715 * @returns the line, or null
1816 */
1917 export function baseLabelOf(
20- requested: Git.BaseMode,
21- data: Git.DiffData | null,
18+ model: Pick<PaneModel, 'requestedMode' | 'data' | 'words'>,
2219 filesCount: number,
23- words: Pick<Backend.BackendWords, 'base'>,
2420 ): string | null {
21+ const { requestedMode, data } = model
22+
2523 if (!data) {
2624 return null
2725 }
@@ -32,15 +30,15 @@
3230 return hasRows ? 'no commits yet — showing staged and new files' : null
3331 }
3432
35- const isPending = requested !== data.mode
36- const isSettledSession = !isPending && requested === 'session'
33+ const isPending = requestedMode !== data.mode
34+ const isSettledSession = !isPending && requestedMode === 'session'
3735
3836 if (isSettledSession) {
3937 return null
4038 }
4139
4240 const phase = isPending ? 'pending' : 'settled'
43- const label = modeLabelOf(requested, data.source, phase, words)
41+ const label = modeLabelOf(model, data.source, phase)
4442
4543 return isPending ? `${label}…` : label
4644 }
@@ -24,7 +24,9 @@
2424 if (!data) {
2525 return {
2626 headline: 'Diff unavailable',
27- hint: `Couldn't read the ${words.diffCommand} — it will retry on the next change`,
27+ hint:
28+ `Couldn't read the ${words.diffCommand} — it will retry on the ` +
29+ 'next change',
2830 }
2931 }
3032
@@ -35,7 +37,7 @@
3537 if (data.isUntrackedWithheld) {
3638 return {
3739 headline: 'No tracked changes',
38- hint: Names.untrackedWithheldTextOf(words.lister),
40+ hint: Names.untrackedWithheldTextOf(words),
3941 }
4042 }
4143
@@ -1,27 +1,26 @@
1-import type Backend from '../../backend'
21 import type Git from '../../git'
2+import type { PaneModel } from '../pane-model'
33
44 /**
5- * A requested mode's name for the base line; branch mode names its base
6- * branch from the data, or says what stands in while none is known. A
7- * working-tree diff names its own base (`HEAD`, a short sha); anything
8- * else falls back to the backend's word for it.
5+ * A requested mode's name for the base line.
96 *
10- * @param requested the mode the person picked
7+ * Branch mode names its base branch from the data, or says what stands in
8+ * while none is known. A working-tree diff names its own base (`HEAD`, a
9+ * short sha); anything else falls back to the backend's word for it.
10+ *
11+ * @param model the mode the person picked, and the backend's words
1112 * @param source what the data on screen compares
1213 * @param phase `pending` while the requested mode's fetch has not landed
13- * @param words the backend's words
1414 * @returns the label without its pending ellipsis
1515 */
1616 export function modeLabelOf(
17- requested: Git.BaseMode,
17+ model: Pick<PaneModel, 'requestedMode' | 'words'>,
1818 source: Git.DiffSource,
1919 phase: 'pending' | 'settled',
20- words: Pick<Backend.BackendWords, 'base'>,
2120 ) {
22- const base = source.kind === 'working-tree' ? source.base : words.base
21+ const base = source.kind === 'working-tree' ? source.base : model.words.base
2322
24- switch (requested) {
23+ switch (model.requestedMode) {
2524 case 'session':
2625 return 'this session'
2726 case 'uncommitted':
@@ -6,9 +6,11 @@
66 import type { Source } from '../source'
77
88 /**
9- * Everything one drawing of the pane reads: the last good fetch, what the
10- * person picked, the selected file's body, the transcript's turns and todos,
11- * and the pinned backend's words and base modes (git's until one is pinned).
9+ * Everything one drawing of the pane reads.
10+ *
11+ * The last good fetch, what the person picked, the selected file's body,
12+ * the transcript's turns and todos, and the pinned backend's words and
13+ * base modes (git's until one is pinned).
1214 */
1315 export type PaneModel = {
1416 words: Backend.BackendWords
@@ -1,8 +1,11 @@
11 /**
2- * What the plugin knows of its pane when `/diff` runs: whether it opened
3- * it and never closed it, and whether it drew when a redraw was asked.
2+ * What the plugin knows of its pane when `/diff` runs.
3+ *
4+ * Whether it opened it and never closed it, whether it drew when a redraw
5+ * was asked, and the terminal's width as last drawn (null before any draw).
46 */
57 export type PaneBelief = {
68 isBelievedOpen: boolean
79 wasDrawnWhenProbed: boolean
10+ columns: number | null
811 }
@@ -1,14 +1,24 @@
1+import Limits from '../limits'
12 import type { PaneBelief } from './pane-belief'
23
34 /**
45 * What `/diff` does, the one place its toggle is decided: close only when
56 * the plugin opened the pane and it still draws; otherwise open, focused.
67 *
7- * The person's `[x]` raises nothing a plugin sees, so a pane believed
8- * open is probed with a redraw first.
8+ * Narrower than the built-in panel shows on, it answers the resize line
9+ * (`too-narrow`). The person's close raises nothing it hooks, so a pane
10+ * believed open is probed with a redraw first; a width not drawn yet opens.
911 *
10- * @param pane the plugin's belief and the probe's answer
11- * @returns `close` or `open`
12+ * @param pane the plugin's belief, the probe's answer and the width
13+ * @returns `close`, `open` or `too-narrow`
1214 */
13-export const paneToggleOf = (pane: PaneBelief): 'open' | 'close' =>
14- pane.isBelievedOpen && pane.wasDrawnWhenProbed ? 'close' : 'open'
15+export function paneToggleOf(
16+ pane: PaneBelief,
17+): 'open' | 'close' | 'too-narrow' {
18+ const isClosing = pane.isBelievedOpen && pane.wasDrawnWhenProbed
19+
20+ const isTooNarrow =
21+ pane.columns !== null && pane.columns < Limits.OPEN_MIN_COLUMNS
22+
23+ return isClosing ? 'close' : isTooNarrow ? 'too-narrow' : 'open'
24+}
@@ -1,0 +1,5 @@
1+/**
2+ * What branch mode resolved its base to, as a base-resolve mark names it,
3+ * the built-in panel's own: the merge base, HEAD itself, or nothing.
4+ */
5+export const BASE_OUTCOMES = ['merge-base', 'head-is-base', 'none'] as const
@@ -1,0 +1,3 @@
1+export * from './base-outcomes.js'
2+
3+export * as default from '.'
@@ -2,12 +2,15 @@
22 * The feature names the plugin marks, kept as the built-in panel's so the
33 * feature surface keeps one series across the swap.
44 *
5- * `read` per fetch round, `tabSwitch` per open or close, `baseSwitch` per
6- * base pick, `baseResolve` once a session.
5+ * `read` per fetch round, `tabSwitch` per `/diff` open or close and per
6+ * close by the pane's own control, `baseSwitch` per base pick, `baseResolve`
7+ * on the first resolve and the first failure, `selectionAttach` per diff
8+ * asked.
79 */
810 export const FEATURES = {
911 read: 'repl_diff_read',
1012 tabSwitch: 'repl_tab_switch',
1113 baseSwitch: 'repl_diff_base_switch',
1214 baseResolve: 'diff_base_resolve',
15+ selectionAttach: 'diff_selection_attach',
1316 } as const
@@ -1,9 +1,11 @@
1+export * from './base-outcomes'
12 export * from './features'
23 export * from './mark-outcome'
34 export * from './recorder'
45 export * from './recorder-of.js'
56 export * from './safely'
67 export * from './shown-triggers'
8+export * from './tabs'
79 export * from './width-bucket-of'
810 export * from './width-buckets'
911
@@ -1,5 +1,12 @@
1+import type { Host } from '../../host'
2+
13 /**
2- * How a marked feature went: fine, or degraded for a reason (the pane never
3- * marks `bad`: a failed fetch keeps the last good diff on screen).
4+ * How a marked feature went: fine, or degraded for a reason, with the
5+ * properties the built-in panel's mark carries beside it.
6+ *
7+ * The pane never marks `bad`: a failed fetch keeps the last good diff on
8+ * screen.
49 */
5-export type MarkOutcome = { kind: 'ok' } | { kind: 'sad'; reason: string }
10+export type MarkOutcome = ({ kind: 'ok' } | { kind: 'sad'; reason: string }) & {
11+ props?: Parameters<Host['mark']>[0]['props']
12+}
@@ -13,7 +13,7 @@
1313 mark: (feature: string, outcome: MarkOutcome) => void
1414
1515 /**
16- * `tengu_plugin_diff_pane_shown`, once a session.
16+ * The built-in panel's `tengu_repl_diff_panel_shown` row, once a session.
1717 */
1818 shown: (
1919 trigger: (typeof SHOWN_TRIGGERS)[number],
@@ -21,7 +21,8 @@
2121 ) => void
2222
2323 /**
24- * `tengu_plugin_diff_ask_attach`: a file's diff rode the next prompt.
24+ * The built-in's `diff_selection_attach` feature mark: a file's diff rode
25+ * the next prompt.
2526 */
26- asked: (lines: number) => void
27+ asked: () => void
2728 }
@@ -1,4 +1,5 @@
11 import type { Host } from '../host'
2+import { FEATURES } from './features'
23 import type { Recorder } from './recorder'
34 import { safely } from './safely'
45 import { SHOWN_TRIGGERS } from './shown-triggers'
@@ -6,8 +7,10 @@
67
78 /**
89 * The plugin's Recorder over the host: each row goes through the telemetry
9- * built-in's `$.telemetry`; a missing noun or a refused row is dropped.
10+ * built-in's `$.telemetry`, named and shaped as the built-in panel's own.
1011 *
12+ * A missing noun or a refused row is dropped.
13+ *
1114 * @param host the engine as session.start bound it
1215 * @returns the recorder
1316 */
@@ -16,13 +19,13 @@
1619 shown: (trigger, bucket) =>
1720 safely(() =>
1821 host.log({
19- event: 'diff_pane_shown',
22+ event: 'tengu_repl_diff_panel_shown',
2023 props: {
2124 trigger: { value: trigger, of: SHOWN_TRIGGERS },
22- width_bucket: { value: bucket, of: WIDTH_BUCKETS },
25+ terminal_width_bucket: { value: bucket, of: WIDTH_BUCKETS },
2326 },
2427 }),
2528 ),
26- asked: lines =>
27- safely(() => host.log({ event: 'diff_ask_attach', props: { lines } })),
29+ asked: () =>
30+ safely(() => host.mark({ feature: FEATURES.selectionAttach, kind: 'ok' })),
2831 })
@@ -1,0 +1,3 @@
1+export * from './tabs.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * Where a tab-switch mark says the person went, named as the built-in
3+ * panel names it: the diff, or the conversation it closes back to.
4+ */
5+export const TABS = ['diff', 'convo'] as const
@@ -1,6 +1,6 @@
11 /**
2- * The terminal-width buckets `diff_pane_shown` reports, the built-in's
3- * `tengu_repl_diff_panel_shown` buckets.
2+ * The terminal-width buckets the pane-shown row reports, the built-in
3+ * panel's own.
44 */
55 export const WIDTH_BUCKETS = [
66 'under_110',
@@ -21,24 +21,24 @@
2121 * Registers the diff pane: `/diff` once the built-in stands down, the
2222 * pane's drawing and refresh, its opening on Claude's first edit, the ask.
2323 *
24- * `session.start` binds the engine once, registers the command (refused
25- * because the built-in holds it, the plugin does nothing) and pins the
26- * backend holding the session's directory (probed again on `/diff` and
27- * first edit until found): what an installed backend probe finds, else git
28- * (backendOf).
24+ * `session.start` registers `/diff`, binds the host every pinned backend
25+ * reads through (currentOf: the latest start's, the engine only before
26+ * bind()), and pins the backend (backendOf), asked again on `/diff`.
2927 *
3028 * @param on the engine's registrar
3129 */
3230 export function register(on: On) {
3331 let host: Host | null = null
3432 let backend: Backend.Backend | null = null
33+ let probing: Promise<boolean> | null = null
3534 let sessionStartMs = 0
3635 let isPaneOpen = false
3736 let hasAutoOpened = false
3837 let columns: number | null = null
39- let hasShownOnce = false
38+ let shownSessionId: string | null = null
4039 let wasDrawnSinceProbe = false
4140 let armed: Ask.ArmedAsk | null = null
41+ let carrying: Ask.ArmedAsk | null = null
4242 let isRefreshing = false
4343 let isRefreshQueued = false
4444 let generation = 0
@@ -48,9 +48,6 @@
4848 const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>()
4949 const loggedBaseKinds = new Set<'ok' | 'sad'>()
5050
51- // The bound host once bind() has run (it sets `host` before pinning), so a
52- // backend pinned under one session.start keeps reading through whatever
53- // host a later session.start bound; `engine` only before that.
5451 const currentOf = (engine: Host): Host => host ?? engine
5552
5653 const backendHostOf = (engine: Host): Backend.BackendHost => ({
@@ -62,12 +59,19 @@
6259 sessionStartMsOf: () => sessionStartMs,
6360 onBranchBase: base => {
6461 const isError = base.kind === 'error'
62+
6563 const outcome: Record.MarkOutcome = isError
6664 ? { kind: 'sad', reason: base.reason }
67- : { kind: 'ok' }
65+ : {
66+ kind: 'ok',
67+ props: {
68+ outcome: { value: base.kind, of: Record.BASE_OUTCOMES },
69+ },
70+ }
6871
6972 if (!loggedBaseKinds.has(outcome.kind)) {
7073 loggedBaseKinds.add(outcome.kind)
74+
7175 Record.recorderOf(currentOf(engine)).mark(
7276 Record.FEATURES.baseResolve,
7377 outcome,
@@ -76,19 +80,40 @@
7680 },
7781 })
7882
79- async function pinBackend(engine: Host): Promise<void> {
83+ function pinBackend(engine: Host): Promise<boolean> {
8084 if (backend) {
81- return
85+ return Promise.resolve(true)
8286 }
8387
88+ probing ??= probeBackend(engine).finally(() => {
89+ probing = null
90+ })
91+
92+ return probing
93+ }
94+
95+ async function probeBackend(engine: Host): Promise<boolean> {
96+ const asked = { isAnswered: true }
97+ const probeHost = backendHostOf(engine)
98+
8499 const probed = await Backend.backendOf(
85- backendHostOf(engine),
100+ {
101+ ...probeHost,
102+ run: (argv, init) =>
103+ probeHost.run(argv, init).catch((error: unknown) => {
104+ asked.isAnswered &&=
105+ argv[0] !== 'git' || !/\baborted\b/.test(messageOf(error))
106+
107+ throw error
108+ }),
109+ },
86110 Backend.INSTALLED_BACKEND_PROBES,
87111 )
112+
88113 backend ??= probed
89114
90115 if (!probed || backend !== probed) {
91- return
116+ return asked.isAnswered || backend !== null
92117 }
93118
94119 const stored = PaneState.baseModeOf(
@@ -96,13 +121,17 @@
96121 .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel))
97122 .catch(() => undefined),
98123 )
124+
99125 const mode = stored && probed.baseModes.includes(stored) ? stored : null
126+
100127 model = {
101128 ...model,
102129 words: probed.words,
103130 baseModes: probed.baseModes,
104131 ...(mode && { requestedMode: mode }),
105132 }
133+
134+ return true
106135 }
107136
108137 function redraw(engine: Host) {
@@ -131,7 +160,7 @@
131160 model.selectedPath,
132161 )
133162
134- async function loadBody(engine: Host): Promise<void> {
163+ async function loadBody(engine: Host): Promise<boolean> {
135164 const { data } = model
136165 const selected = selectedOf()
137166
@@ -139,26 +168,29 @@
139168 bodyKey = null
140169 model = { ...model, body: null, bodyState: 'idle' }
141170
142- return
171+ return false
143172 }
144173
145174 const key = `${generation}|${data.baseRef}|${selected.path}`
146175
147176 if (key === bodyKey) {
148- return
177+ return false
149178 }
150179
151180 bodyKey = key
152181 model = { ...model, body: null, bodyState: 'loading' }
153182 redraw(engine)
183+
154184 const body = await backend.fetchFileHunks(data, selected)
155185
156186 if (bodyKey !== key) {
157- return
187+ return body === null
158188 }
159189
160190 model = { ...model, body, bodyState: body ? 'ready' : 'failed' }
161191 redraw(engine)
192+
193+ return body === null
162194 }
163195
164196 function startPoll(engine: Host, pinned: Backend.Backend) {
@@ -171,6 +203,7 @@
171203 timers.get('poll')?.cancel()
172204 polled.toplevel = pinned.repository.toplevel
173205 polled.headKey = ''
206+
174207 timers.set(
175208 'poll',
176209 engine.every(Limits.HEAD_POLL_MS, () => {
@@ -180,6 +213,7 @@
180213
181214 void readHeadKey().then(key => {
182215 const hasMoved = polled.headKey !== '' && key !== polled.headKey
216+
183217 polled.headKey = key
184218
185219 if (hasMoved) {
@@ -198,8 +232,10 @@
198232 }
199233
200234 isRefreshing = true
235+
201236 const record = Record.recorderOf(engine)
202237 const pinned = backend
238+
203239 const fetched = (): Promise<Git.FetchOutcome> =>
204240 pinned
205241 ? pinned.fetchDiff(model.requestedMode)
@@ -207,10 +243,12 @@
207243
208244 try {
209245 model = { ...model, isLoading: model.data === null }
246+
210247 const [outcome, messages] = await Promise.all([
211248 fetched(),
212249 engine.messages().catch((): SessionMessage[] => []),
213250 ])
251+
214252 model = PaneState.afterFetch(model, { outcome, messages })
215253
216254 switch (outcome.kind) {
@@ -221,9 +259,9 @@
221259 kind: 'sad',
222260 reason: 'git_diff_failed',
223261 })
262+
224263 break
225264 case 'data':
226- record.mark(Record.FEATURES.read, { kind: 'ok' })
227265 generation += 1
228266
229267 if (pinned) {
@@ -233,7 +271,23 @@
233271 break
234272 }
235273
236- await loadBody(engine)
274+ const hasHunksFailed = await loadBody(engine)
275+
276+ if (outcome.kind === 'data') {
277+ record.mark(
278+ Record.FEATURES.read,
279+ hasHunksFailed
280+ ? { kind: 'sad', reason: 'git_hunks_failed' }
281+ : { kind: 'ok' },
282+ )
283+ }
284+ } catch (error) {
285+ record.mark(Record.FEATURES.read, {
286+ kind: 'sad',
287+ reason: 'git_diff_threw',
288+ })
289+
290+ throw error
237291 } finally {
238292 isRefreshing = false
239293 redraw(engine)
@@ -247,6 +301,7 @@
247301
248302 function scheduleRefresh(engine: Host): void {
249303 timers.get('refresh')?.cancel()
304+
250305 timers.set(
251306 'refresh',
252307 engine.after(Limits.REFRESH_DEBOUNCE_MS, () => {
@@ -264,12 +319,12 @@
264319 const isManual = trigger === 'manual'
265320 await engine.openPane(isManual ? { ...pane, ...Names.FOCUSED_PANE } : pane)
266321 isPaneOpen = true
267- const record = Record.recorderOf(engine)
268- record.mark(Record.FEATURES.tabSwitch, { kind: 'ok' })
269322
270- if (!hasShownOnce) {
271- hasShownOnce = true
272- record.shown(trigger, Record.widthBucketOf(columns))
323+ const sessionId = await engine.sessionId().catch(() => null)
324+
325+ if (sessionId !== null && sessionId !== shownSessionId) {
326+ shownSessionId = sessionId
327+ Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns))
273328 }
274329
275330 void refresh(engine)
@@ -278,9 +333,15 @@
278333 async function closePane(engine: Host): Promise<void> {
279334 await engine.closePane({ id: Names.PANE_ID })
280335 isPaneOpen = false
281- Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { kind: 'ok' })
282336 }
283337
338+ function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) {
339+ Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, {
340+ kind: 'ok',
341+ props: { tab: { value: tab, of: Record.TABS } },
342+ })
343+ }
344+
284345 async function wasDrawnWhenProbed(engine: Host): Promise<boolean> {
285346 wasDrawnSinceProbe = false
286347 engine.invalidate()
@@ -297,11 +358,15 @@
297358 }
298359
299360 const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
361+
300362 await pinBackend(engine)
363+
301364 const isKeptOpen = preference === true
365+
302366 const floor = isKeptOpen
303367 ? Limits.OPEN_MIN_COLUMNS
304368 : Limits.AUTO_OPEN_MIN_COLUMNS
369+
305370 const isEligible =
306371 preference !== false &&
307372 columns !== null &&
@@ -347,9 +412,12 @@
347412
348413 bodyKey = null
349414 model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' }
415+
350416 Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, {
351417 kind: 'ok',
418+ props: { mode: { value: mode, of: model.baseModes } },
352419 })
420+
353421 const toplevel = model.data?.repository.toplevel
354422
355423 if (toplevel !== undefined) {
@@ -364,9 +432,11 @@
364432 chooseSource: value => {
365433 const index = Number(value)
366434 const isTurn = value !== 'current' && Number.isInteger(index)
435+
367436 const source: PaneState.Source = isTurn
368437 ? { kind: 'turn', index }
369438 : { kind: 'current' }
439+
370440 model = { ...model, source, selectedPath: null }
371441 void loadBody(engine)
372442 redraw(engine)
@@ -381,6 +451,12 @@
381451
382452 arm(engine, path)
383453 },
454+ close: () => {
455+ void closePane(engine)
456+ .then(() => markTabSwitch(engine, 'convo'))
457+ .then(() => engine.storeSet(Names.STORE_OPEN_KEY, false))
458+ .catch(() => undefined)
459+ },
384460 })
385461
386462 function arm(engine: Host, path: string) {
@@ -391,16 +467,19 @@
391467 model.body?.hunks ??
392468 [],
393469 )
470+
394471 model = { ...model, armedPath: path }
472+
395473 engine.status(
396474 `${Views.sanitizeName(path)} rides your next prompt (press ` +
397475 `asked ✓ to drop it)`,
398476 )
477+
399478 redraw(engine)
400479 }
401480
402481 async function bind(engine: Host): Promise<void> {
403- sessionStartMs = engine.now()
482+ sessionStartMs = await engine.now()
404483
405484 try {
406485 await engine.registerCommand(COMMAND_SPEC)
@@ -437,6 +516,7 @@
437516 openPane: pane => $.ui.open(pane),
438517 closePane: pane => $.ui.close(pane),
439518 registerCommand: spec => $.command.register(spec),
519+ sessionId: () => $.session.id(),
440520 mark: entry => $.telemetry.mark(entry),
441521 log: entry => $.telemetry.log(entry),
442522 })
@@ -457,18 +537,21 @@
457537 return next(e)
458538 }
459539
460- const { Box, Text, Button, Select } = await $.ui.resolve(e)
540+ const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
541+
461542 wasDrawnSinceProbe = true
462543 columns = e.viewport?.columns ?? columns
463544 model = { ...model, isFocused: e.props.isFocused }
464545
465546 return Views.paneView(
466547 {
467- ui: { Box, Text, Button, Select },
548+ ui: { Box, Text, Button, Select, Code },
468549 actions: actionsOf(host),
469550 columns: e.props.bodyColumns,
551+ rows: e.props.scroll.bodyRows,
470552 },
471553 model,
554+ e.props.placement,
472555 )
473556 })
474557
@@ -477,18 +560,29 @@
477560 return next(e)
478561 }
479562
480- await pinBackend(host)
563+ const isAnswered = (await pinBackend(host)) || (await pinBackend(host))
481564
482565 if (!backend) {
483- return { text: Names.NOT_IN_REPOSITORY_TEXT }
566+ return {
567+ text: isAnswered
568+ ? Names.NOT_IN_REPOSITORY_TEXT
569+ : Names.GIT_UNANSWERED_TEXT,
570+ }
484571 }
485572
486- const isOpening =
487- PaneToggle.paneToggleOf({
488- isBelievedOpen: isPaneOpen,
489- wasDrawnWhenProbed: isPaneOpen && (await wasDrawnWhenProbed(host)),
490- }) === 'open'
573+ const toggle = PaneToggle.paneToggleOf({
574+ isBelievedOpen: isPaneOpen,
575+ wasDrawnWhenProbed: isPaneOpen && (await wasDrawnWhenProbed(host)),
576+ columns,
577+ })
578+
579+ if (toggle === 'too-narrow') {
580+ return { text: Names.RESIZE_TERMINAL_TEXT }
581+ }
582+
583+ const isOpening = toggle === 'open'
491584 await (isOpening ? openPane(host, 'manual') : closePane(host))
585+ markTabSwitch(host, isOpening ? 'diff' : 'convo')
492586 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
493587
494588 return {}
@@ -552,17 +646,50 @@
552646 })
553647
554648 on('prompt.submit', async ($, e, next) => {
555- const result = await next(e)
556649 const asked = armed
557650
558- if (!host || !asked || result.drop !== undefined) {
559- return result
651+ if (!host || !asked || carrying === asked) {
652+ return next(e)
560653 }
561654
562- disarm(host)
563- redraw(host)
564- Record.recorderOf(host).asked(asked.lines)
655+ const context = e.context ?? []
565656
566- return { ...result, context: [...(result.context ?? []), asked.text] }
657+ const text = Ask.fittedAskTextOf(
658+ asked.text,
659+ Limits.PROMPT_CONTEXT_MAX_CHARS -
660+ context.reduce((sum, entry) => sum + entry.length, 0),
661+ )
662+
663+ if (text === undefined) {
664+ disarm(host)
665+
666+ host.status(
667+ `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` +
668+ `and was dropped`,
669+ )
670+
671+ redraw(host)
672+
673+ return next(e)
674+ }
675+
676+ carrying = asked
677+
678+ try {
679+ const result = await next({ ...e, context: [...context, text] })
680+
681+ if (result.drop === undefined) {
682+ Record.recorderOf(host).asked()
683+
684+ if (armed === asked) {
685+ disarm(host)
686+ redraw(host)
687+ }
688+ }
689+
690+ return result
691+ } finally {
692+ carrying = null
693+ }
567694 })
568695 }
Dmods/diff/hooks/telemetry-noun.d.ts+0 -56
@@ -1,56 +1,0 @@
1-/**
2- * The `$.telemetry` noun as this plugin calls it, declared for a build of
3- * this folder on its own; no module imports it.
4- *
5- * The telemetry plugin adds the noun in the engine.create fold on internal
6- * builds and nowhere else: where no plugin provides it the calls throw and
7- * record/ drops the row. The engine's repository leaves this file out.
8- *
9- * @entry
10- */
11-declare module 'claude-code' {
12- /**
13- * How one use of a feature went: as hoped, degraded, or failed outright.
14- */
15- type DiffTelemetryMarkKind = 'ok' | 'sad' | 'bad'
16-
17- /**
18- * A string property: the value and the list it is chosen from.
19- */
20- type DiffTelemetryChoice = { value: string; of: readonly string[] }
21-
22- /**
23- * One property value a row may carry.
24- */
25- type DiffTelemetryProp = number | boolean | DiffTelemetryChoice
26-
27- /**
28- * What `$.telemetry.log` takes: the event's name after the prefix, and
29- * its properties by snake_case key.
30- */
31- type DiffTelemetryLogEntry = {
32- event: string
33- props?: Readonly<Record<string, DiffTelemetryProp>>
34- }
35-
36- /**
37- * What `$.telemetry.mark` takes: the feature, how it went, and why when
38- * not ok.
39- */
40- type DiffTelemetryMarkEntry = {
41- feature: string
42- kind: DiffTelemetryMarkKind
43- reason?: string
44- }
45-
46- interface EngineInterface {
47- /**
48- * A plugin's analytics, one first-party row per call; present only
49- * where the telemetry plugin is seated.
50- */
51- telemetry: {
52- log: (entry: DiffTelemetryLogEntry) => Promise<void>
53- mark: (entry: DiffTelemetryMarkEntry) => Promise<void>
54- }
55- }
56-}
@@ -21,6 +21,7 @@
2121 const latest = messages
2222 .flatMap(message => message.toolUses)
2323 .findLast(use => use.tool === Tools.TODO_TOOL)
24+
2425 const statuses = arrayOf(latest?.input.todos).map(todo =>
2526 isRecord(todo) ? todo.status : null,
2627 )
@@ -14,6 +14,7 @@
1414 .filter(isRecord)
1515 .flatMap(hunk => {
1616 const { oldStart, newStart, lines } = hunk
17+
1718 const isHunk =
1819 typeof oldStart === 'number' &&
1920 typeof newStart === 'number' &&
@@ -22,17 +22,21 @@
2222 ): Types.TurnDiff {
2323 const text = rows[0]?.text ?? ''
2424 const isShort = [...text].length <= PREVIEW_CHARS
25+
2526 const preview = isShort
2627 ? text
2728 : `${[...text].slice(0, PREVIEW_CHARS - 1).join('')}…`
29+
2830 const edits = rows
2931 .flatMap(row => (row.role === 'assistant' ? row.toolUses : []))
3032 .map(use => fileEditOf(use.result))
3133 .filter((edit): edit is Types.TurnFile => edit !== null)
34+
3235 const byPath = new Map<string, Types.TurnFile>()
3336
3437 for (const edit of edits) {
3538 const earlier = byPath.get(edit.path)
39+
3640 byPath.set(
3741 edit.path,
3842 earlier
@@ -12,16 +12,17 @@
1212 import Sections from '../sections'
1313 import { fileRowOf } from './file-row-of'
1414 import { listBodyOf } from './list-body-of'
15+import { messagePaneOf } from './message-pane-of'
1516
1617 /**
1718 * The pane over the repository's diff now (ReplDiffSidebarBody, one file's
1819 * body at a time): header, base line, todo bar, pickers, rows, detail.
1920 *
20- * The tests-and-generated toggle sits above the rows; the elision count, the
21- * withheld-untracked note, the pre-session line and rows below them; the
22- * selected file last.
21+ * The toggle sits above the rows; the elision count, withheld-untracked
22+ * note and pre-session line below, with blank rows as the built-in leaves.
23+ * With nothing to list, messagePaneOf draws the body, the header a close.
2324 *
24- * @param kit the elements, the handlers, the width
25+ * @param kit the elements, the handlers, the width, the rows
2526 * @param model the pane's state
2627 * @returns the pane's tree
2728 */
@@ -37,53 +38,83 @@
3738 }
3839
3940 if (!data && !model.hasSettled) {
40- return <Box>{Sections.dimNote(kit, 'Loading diff…')}</Box>
41+ return messagePaneOf(kit, {
42+ top: [Sections.headerView(kit, null)],
43+ message: ['Loading diff…'],
44+ controls: null,
45+ earlier: null,
46+ rest: [],
47+ })
4148 }
4249
4350 const noise = model.isNoiseShown ? 'shown' : 'hidden'
4451 const preSession = model.isPreSessionShown ? 'shown' : 'hidden'
4552 const partition = PaneState.partitionOf(data?.files ?? [], noise)
53+
4654 const totals = data
4755 ? PaneState.headerTotalsOf(data, partition)
4856 : PaneState.ZERO_TOTALS
57+
4958 const empty = PaneState.emptyStateOf(data, totals.filesCount, model.words)
50- const baseLabel = PaneState.baseLabelOf(
51- model.requestedMode,
52- data,
53- totals.filesCount,
54- model.words,
55- )
59+ const baseLabel = PaneState.baseLabelOf(model, totals.filesCount)
60+
5661 const selected = PaneState.selectionOf(
5762 PaneState.listedOf(partition, preSession),
5863 model.selectedPath,
5964 )
65+
6066 const rowOf = (file: Git.FileStat): RenderElement =>
6167 Sections.fileRow(kit, fileRowOf(file, selected?.path ?? null), () =>
6268 kit.actions.selectFile(file.path),
6369 )
70+
6471 const noteOf = (text: string | null): RenderElement | null =>
6572 text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text))
73+
6674 const noiseFace = model.isNoiseShown ? 'hide' : 'show'
6775 const noiseCount = Layout.plural(partition.noiseCount, 'test')
6876 const hasNoise = partition.noiseCount > 0
77+
6978 const noiseToggle = hasNoise
7079 ? Sections.toggleRow(kit, 'noise', {
7180 label: `${noiseCount}/generated (${noiseFace})`,
7281 onPress: kit.actions.toggleNoise,
7382 })
7483 : null
84+
7585 const earlierFace = model.isPreSessionShown ? 'hide' : 'show'
7686 const earlierCount = Layout.plural(partition.preSession.length, 'file')
7787 const hasEarlier = partition.preSession.length > 0
88+
7889 const earlierToggle = hasEarlier
7990 ? Sections.toggleRow(kit, 'presession', {
8091 label: `+${earlierCount} edited before this session (${earlierFace})`,
8192 onPress: kit.actions.togglePreSession,
8293 })
8394 : null
84- const earlierRows = model.isPreSessionShown ? partition.preSession : []
95+
96+ const earlierFiles = model.isPreSessionShown ? partition.preSession : []
97+ const isEarlierListed = earlierFiles.length > 0
98+
99+ const earlierRows = isEarlierListed
100+ ? [<Box height={1} />, ...earlierFiles.map(rowOf)]
101+ : []
102+
85103 const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty
86104 const listed = listBodyOf(kit, { data, partition, totals, empty })
105+ const notes = listed.filter(line => typeof line === 'string')
106+ const hasRows = notes.length < listed.length
107+ const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes
108+ const header = Sections.headerView(kit, empty ? null : totals)
109+
110+ const notShownNote = noteOf(
111+ totals.notShown > 0 ? `${totals.notShown} not shown` : null,
112+ )
113+
114+ const untrackedNote = noteOf(
115+ isUntrackedNoted ? Names.untrackedWithheldTextOf(model.words) : null,
116+ )
117+
87118 const detail = selected
88119 ? [
89120 Sections.divider(kit),
@@ -104,25 +135,39 @@
104135 ]
105136 : []
106137
107- return (
138+ const isMessageShown = message.length > 0
139+
140+ return isMessageShown ? (
141+ messagePaneOf(kit, {
142+ top: Sections.present([
143+ header,
144+ noteOf(baseLabel),
145+ Sections.todoBar(kit, model),
146+ noiseToggle,
147+ notShownNote,
148+ untrackedNote,
149+ ]),
150+ message,
151+ controls: Sections.controlsView(kit, model),
152+ earlier: earlierToggle,
153+ rest: [...earlierRows, ...detail],
154+ })
155+ ) : (
108156 <Box flexDirection="column">
109157 {Sections.present([
110- Sections.headerView(kit, empty?.headline ?? null, totals),
158+ header,
111159 noteOf(baseLabel),
112- Sections.todoBar(kit, model.todos),
160+ Sections.todoBar(kit, model),
113161 Sections.controlsView(kit, model),
114162 noiseToggle,
115163 ...listed.map(line =>
116164 typeof line === 'string' ? noteOf(line) : rowOf(line),
117165 ),
118- noteOf(totals.notShown > 0 ? `${totals.notShown} not shown` : null),
119- noteOf(
120- isUntrackedNoted
121- ? Names.untrackedWithheldTextOf(model.words.lister)
122- : null,
123- ),
166+ notShownNote,
167+ untrackedNote,
168+ hasEarlier ? <Box height={1} /> : null,
124169 earlierToggle,
125- ...earlierRows.map(rowOf),
170+ ...earlierRows,
126171 ...detail,
127172 ])}
128173 </Box>
@@ -14,6 +14,7 @@
1414 selectedPath: string | null,
1515 ): Sections.FileRowModel {
1616 const isRenamed = file.renamedFrom !== null
17+
1718 const note = file.isUntracked
1819 ? 'untracked'
1920 : file.isBinary
@@ -1,5 +1,6 @@
11 export * from './current-pane.jsx'
22 export * from './file-row-of'
33 export * from './list-body-of'
4+export * from './message-pane-of'
45
56 export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The rows the pre-session line takes at the pane's foot: a blank row, the
3+ * line, and a blank row under it.
4+ */
5+export const FOOT_ROWS = 3
@@ -1,0 +1,3 @@
1+export * from './foot-rows.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './foot-rows'
2+export * from './message-pane'
3+export * from './message-pane-of.jsx'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './message-pane.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+import type { RenderElement } from 'claude-code'
2+
3+/**
4+ * What stands around a message in the pane: the header block, the message,
5+ * the pickers, the pre-session line, and what is listed under it.
6+ */
7+export type MessagePane = {
8+ top: readonly RenderElement[]
9+ message: readonly string[]
10+ controls: RenderElement | null
11+ earlier: RenderElement | null
12+ rest: readonly RenderElement[]
13+}
@@ -1,0 +1,85 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import type { Kit } from '../../kit'
7+import Layout from '../../layout'
8+import { FOOT_ROWS } from './foot-rows'
9+import type { MessagePane } from './message-pane'
10+
11+/**
12+ * The pane while a message stands in for the list (ReplDiffSidebar's
13+ * centered body): the header, a blank row, the message, the pickers.
14+ *
15+ * With nothing listed below and room, the lines sit where the built-in
16+ * centers them (half the spare rows above), the pickers under them;
17+ * otherwise all stacks from the top, the pickers over the pre-session line.
18+ *
19+ * @param kit the elements, the width, the body's rows
20+ * @param pane the header block, the message's lines, the pickers, the
21+ * pre-session line, and what is listed under it
22+ * @returns the pane's tree
23+ */
24+export function messagePaneOf(kit: Kit, pane: MessagePane): RenderElement {
25+ const { Box, Text } = kit.ui
26+ const { top, controls, earlier, rest } = pane
27+
28+ const lines = pane.message.flatMap(text =>
29+ Layout.wrappedLines(Layout.sanitizeName(text), kit.columns),
30+ )
31+
32+ const drawn = lines.map(line => <Text dimColor>{line}</Text>)
33+ const controlRows = controls === null ? [] : [controls]
34+ const earlierRows = earlier === null ? [] : [earlier]
35+
36+ const isControlsAboveEarlier =
37+ controlRows.length > 0 || earlierRows.length === 0
38+
39+ const aboveEarlier = isControlsAboveEarlier
40+ ? controlRows
41+ : [<Box height={1} />]
42+
43+ const footRows = earlier === null ? 0 : FOOT_ROWS
44+ const middleRows = kit.rows - top.length - 1 - footRows
45+ const above = Math.floor((middleRows - lines.length) / 2)
46+
47+ const isCentered =
48+ rest.length === 0 && middleRows - above - lines.length >= controlRows.length
49+
50+ if (!isCentered) {
51+ return (
52+ <Box flexDirection="column">
53+ {[
54+ ...top,
55+ <Box height={1} />,
56+ ...drawn,
57+ ...aboveEarlier,
58+ ...earlierRows,
59+ ...rest,
60+ ]}
61+ </Box>
62+ )
63+ }
64+
65+ const spacer = above > 0 ? [<Box height={above} />] : []
66+
67+ const foot = earlierRows.map(row => (
68+ <Box flexDirection="column" marginTop={1} paddingBottom={1}>
69+ {row}
70+ </Box>
71+ ))
72+
73+ return (
74+ <Box flexDirection="column" height={kit.rows}>
75+ {[
76+ ...top,
77+ <Box height={1} />,
78+ <Box flexDirection="column" height={middleRows} alignItems="center">
79+ {[...spacer, ...drawn, ...controlRows]}
80+ </Box>,
81+ ...foot,
82+ ]}
83+ </Box>
84+ )
85+}
Dmods/diff/hooks/views/detail/backgrounds/backgrounds.ts+0 -14
@@ -1,14 +1,0 @@
1-import type Types from '../types'
2-
3-/**
4- * The theme key behind a run of each kind: the added and removed bands, and
5- * none behind context, which draws on the terminal's own background.
6- *
7- * The theme's `background` key is the background-task accent, a cyan.
8- */
9-export const BACKGROUNDS: Readonly<Record<Types.LineKind, string | undefined>> =
10- {
11- added: 'diffAdded',
12- removed: 'diffRemoved',
13- context: undefined,
14- }
Dmods/diff/hooks/views/detail/backgrounds/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './backgrounds.js'
2-
3-export * as default from '.'
@@ -1,0 +1,81 @@
1+import Git from '../../../git'
2+import { HUNK_DIVIDER } from '../hunk-divider'
3+import { HUNK_DIVIDER_NODES } from '../hunk-divider-nodes'
4+import { hunkSourceOf } from '../hunk-source-of'
5+import { leadingHunkOf } from '../leading-hunk-of'
6+import { MAX_BODY_CHARS } from '../max-body-chars'
7+import { MAX_BODY_NODES } from '../max-body-nodes'
8+import { MAX_CODE_CHARS } from '../max-code-chars'
9+import { subHunksOf } from '../sub-hunks-of'
10+import type Types from '../types'
11+import { drawnLineOf } from './drawn-line-of'
12+
13+/**
14+ * A file's hunks as the sources of `Code` blocks, each at most
15+ * MAX_CODE_CHARS, hunks sharing a block (the engine's ellipsis between).
16+ *
17+ * A new block at a hunk gets HUNK_DIVIDER; a hunk too long for one is split
18+ * (subHunksOf) into blocks stacked with none. The body stops, truncated, at
19+ * the whole lines MAX_BODY_CHARS and MAX_BODY_NODES hold, as the host counts.
20+ *
21+ * @param hunks the file's hunks
22+ * @returns the blocks in order, and whether any line was left out or cut
23+ */
24+export function codeBlocksOf(hunks: readonly Git.Hunk[]): Types.CodeBody {
25+ const blocks: Types.CodeBlock[] = []
26+ let chars = 0
27+ let nodes = 0
28+ let isTruncated = false
29+
30+ for (const hunk of hunks) {
31+ const lines = hunk.lines.filter(Git.isBodyLine).map(drawnLineOf)
32+ const split = subHunksOf({ ...hunk, lines }, MAX_CODE_CHARS)
33+ isTruncated ||= split.isTruncated
34+
35+ for (const [at, piece] of split.hunks.entries()) {
36+ const last = blocks.at(-1)
37+ const source = hunkSourceOf(piece)
38+ const isHunkStart = at === 0
39+
40+ const isJoining =
41+ isHunkStart &&
42+ last !== undefined &&
43+ last.source.length + 1 + source.length <= MAX_CODE_CHARS
44+
45+ const hasDivider = isHunkStart && !isJoining && last !== undefined
46+ const overhead = isJoining ? 1 : hasDivider ? HUNK_DIVIDER.length : 0
47+
48+ const addedNodes = isJoining
49+ ? 0
50+ : 1 + (hasDivider ? HUNK_DIVIDER_NODES : 0)
51+
52+ const room = MAX_BODY_CHARS - chars - overhead
53+ const fitted = source.length <= room ? piece : leadingHunkOf(piece, room)
54+ const isOverNodes = nodes + addedNodes > MAX_BODY_NODES
55+
56+ if (isOverNodes || fitted.lines.length === 0) {
57+ return { blocks, isTruncated: true }
58+ }
59+
60+ const text = hunkSourceOf(fitted)
61+
62+ if (isJoining) {
63+ blocks.pop()
64+ }
65+
66+ blocks.push({
67+ source: isJoining ? `${last.source}\n${text}` : text,
68+ hasDivider: isJoining ? last.hasDivider : hasDivider,
69+ })
70+
71+ chars += overhead + text.length
72+ nodes += addedNodes
73+
74+ if (fitted.lines.length < piece.lines.length) {
75+ return { blocks, isTruncated: true }
76+ }
77+ }
78+ }
79+
80+ return { blocks, isTruncated }
81+}
@@ -1,0 +1,15 @@
1+import Layout from '../../../layout'
2+
3+/**
4+ * A hunk line made safe for a `Code` source: its marker and its tabs kept,
5+ * the engine laying tabs out as it does every diff it draws.
6+ *
7+ * Between the tabs sanitizeLine's rule holds: every control, format and
8+ * invisible character dropped, so nothing can reorder or hide the line.
9+ *
10+ * @param line a body line, its `+`, `-` or space marker first
11+ * @returns the drawable line
12+ */
13+export const drawnLineOf = (line: string) =>
14+ line.slice(0, 1) +
15+ line.slice(1).split('\t').map(Layout.sanitizeLine).join('\t')
@@ -1,0 +1,3 @@
1+export * from './drawn-line-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './code-blocks-of.js'
2+export * from './drawn-line-of'
3+
4+export * as default from '.'
@@ -3,9 +3,10 @@
33 import type PaneState from '../../../pane-state'
44
55 /**
6- * The selected file as its body draws it: the path, what kind of row it is,
7- * its hunks once read, whether it is armed for the next prompt, and the
8- * backend's words for an untracked row's note.
6+ * The selected file as its body draws it.
7+ *
8+ * The path, what kind of row it is, its hunks once read, whether it is armed
9+ * for the next prompt, and the backend's words for an untracked row's note.
910 */
1011 export type DetailModel = {
1112 words: Pick<Backend.BackendWords, 'untrackedNoteOf'>
@@ -5,22 +5,20 @@
55
66 import type { Kit } from '../kit'
77 import Layout from '../layout'
8+import Sections from '../sections'
9+import { codeBlocksOf } from './code-blocks-of'
810 import type { DetailModel } from './detail-model'
9-import { gutterCellsOf } from './gutter-cells-of'
10-import { hunkBody } from './hunk-body'
11-import { linesOf } from './lines-of'
12-import { MARKER_CELLS } from './marker-cells'
13-import { maxRowsOf } from './max-rows-of'
11+import { HUNK_DIVIDER } from './hunk-divider'
12+import { MAX_CODE_CHARS } from './max-code-chars'
1413 import { placeholderOf } from './placeholder-of'
15-import { rowsOf } from './rows-of'
1614
1715 /**
18- * The selected file under the list: its bold path and asides (renamed
19- * from, untracked, truncated) cut to the width, the ask Button, the body.
16+ * The selected file under the list: its bold path and asides, the ask
17+ * Button, the built-in's dim rule under them, the body.
2018 *
21- * The body is a placeholder or the hunks (cut to the rows the body's char
22- * and node budgets hold, maxRowsOf), then the footer; the gutter fits the
23- * largest number.
19+ * Each name is cut from its start to the width. The body is a placeholder,
20+ * or the hunks as the engine's diff `Code` blocks (codeBlocksOf), a dim
21+ * divider where two meet, then the footer when anything was cut.
2422 *
2523 * @param kit the elements and the width
2624 * @param detail the selected file
@@ -32,34 +30,26 @@
3230 detail: DetailModel,
3331 onToggleAsk: () => void,
3432 ): RenderElement {
35- const { Box, Text, Button } = kit.ui
33+ const { Box, Text, Button, Code } = kit.ui
3634 const placeholder = placeholderOf(detail)
37- const hunks = placeholder ? [] : (detail.body?.hunks ?? [])
38- const lines = linesOf(hunks)
39- const gutterCells = gutterCellsOf(lines)
40- const contentCells = Math.max(1, kit.columns - gutterCells - MARKER_CELLS)
41- const rows = placeholder ? [] : rowsOf(lines, contentCells)
42- const cells = { gutterCells, contentCells }
43- const maxRows = maxRowsOf(rows, cells)
44- const isTruncated = detail.body?.isTruncated === true || rows.length > maxRows
45- const nameOf = (path: string) =>
46- Layout.truncateStart(Layout.sanitizeName(path), kit.columns)
35+ const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? []))
36+ const isTruncated = detail.body?.isTruncated === true || code.isTruncated
37+ const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS)
38+
39+ const nameOf = (name: string) =>
40+ Layout.truncateStart(Layout.sanitizeName(name), kit.columns)
41+
4742 const renamedFrom =
4843 detail.renamedFrom === null ? null : nameOf(detail.renamedFrom)
44+
4945 const asides = [
5046 renamedFrom === null ? null : `renamed from ${renamedFrom}`,
5147 detail.isUntracked ? 'untracked' : null,
5248 isTruncated ? 'truncated' : null,
5349 ].filter(word => word !== null)
50+
5451 const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})`
55- const notes = (placeholder ?? []).map(line => (
56- <Text dimColor italic wrap="wrap">
57- {line}
58- </Text>
59- ))
60- const body = placeholder
61- ? notes
62- : [hunkBody(kit.ui, rows.slice(0, maxRows), cells)]
52+
6353 const ask = placeholder
6454 ? []
6555 : [
@@ -67,6 +57,7 @@
6757 {detail.isArmed ? 'asked ✓' : 'ask'}
6858 </Button>,
6959 ]
60+
7061 const footer = isTruncated
7162 ? [
7263 <Text dimColor italic>
@@ -75,18 +66,34 @@
7566 ]
7667 : []
7768
78- const header = (
79- <Box flexDirection="row">
69+ const notes = (placeholder ?? []).map(line => (
70+ <Text dimColor italic wrap="wrap">
71+ {line}
72+ </Text>
73+ ))
74+
75+ const hunks = code.blocks.flatMap(block => [
76+ ...(block.hasDivider ? [<Text dimColor>{HUNK_DIVIDER}</Text>] : []),
77+ <Code source={block.source} format="diff" path={path} />,
78+ ])
79+
80+ return (
81+ <Box flexDirection="column">
8082 {[
81- <Text bold wrap="truncate-start">
82- {nameOf(detail.path)}
83- </Text>,
84- <Text dimColor>{aside}</Text>,
85- <Box flexGrow={1} />,
86- ...ask,
83+ <Box flexDirection="row">
84+ {[
85+ <Text bold wrap="truncate-start">
86+ {nameOf(detail.path)}
87+ </Text>,
88+ <Text dimColor>{aside}</Text>,
89+ <Box flexGrow={1} />,
90+ ...ask,
91+ ]}
92+ </Box>,
93+ Sections.divider(kit),
94+ ...(placeholder ? notes : hunks),
95+ ...footer,
8796 ]}
8897 </Box>
8998 )
90-
91- return <Box flexDirection="column">{[header, ...body, ...footer]}</Box>
9299 }
Dmods/diff/hooks/views/detail/gutter-cells-of/gutter-cells-of.ts+0 -12
@@ -1,12 +1,0 @@
1-import type Types from '../types'
2-
3-/**
4- * How wide the line-number gutter is for these lines: the longest number's
5- * digits and one leading cell (StructuredDiff's digits + 1).
6- *
7- * @param lines the numbered lines
8- * @returns the gutter's width in cells
9- */
10-export const gutterCellsOf = (lines: readonly Types.DiffLine[]) =>
11- String(lines.reduce((most, line) => Math.max(most, line.lineNumber), 0))
12- .length + 1
Dmods/diff/hooks/views/detail/gutter-cells-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './gutter-cells-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/hunk-body/body-cells/body-cells.ts+0 -8
@@ -1,8 +1,0 @@
1-/**
2- * The two widths a body is drawn in: the gutter's and the content's, the
3- * marker cells between them.
4- */
5-export type BodyCells = {
6- gutterCells: number
7- contentCells: number
8-}
Dmods/diff/hooks/views/detail/hunk-body/body-cells/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './body-cells.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/hunk-body/hunk-body.tsx+0 -114
@@ -1,114 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement, RenderNode } from 'claude-code'
5-
6-import type Kit from '../../kit'
7-import Layout from '../../layout'
8-import { BACKGROUNDS } from '../backgrounds'
9-import { MARKER_CELLS } from '../marker-cells'
10-import { MAX_RUN_CHARS } from '../max-run-chars'
11-import { runsOf } from '../runs-of'
12-import type Types from '../types'
13-import { WORD_BACKGROUNDS } from '../word-backgrounds'
14-import type { BodyCells } from './body-cells'
15-
16-/**
17- * A file's rows drawn as StructuredDiff's fallback draws them, in few
18- * nodes: one dim gutter Text beside a column of one Text per run.
19- *
20- * Added and removed runs sit on their theme bands, changed words on the
21- * word bands, every row is padded to the band's width less its text's
22- * chargedWidth; a run's text and the gutter split at MAX_RUN_CHARS a string.
23- *
24- * @param ui the surface's elements
25- * @param rows the laid-out rows (rowsOf), each exactly one terminal row
26- * @param cells the gutter's and the content's widths
27- * @returns the body element
28- */
29-export function hunkBody(
30- ui: Kit.Ui,
31- rows: readonly Types.Row[],
32- cells: BodyCells,
33-): RenderElement {
34- const { Box, Text } = ui
35- const bandCells = MARKER_CELLS + cells.contentCells
36-
37- function gutterChildren(): string[] {
38- const children: string[] = []
39- let pending = ''
40-
41- rows.forEach((row, at) => {
42- const label = row.gutter.padStart(cells.gutterCells)
43- const text = `${at === 0 ? '' : '\n'}${label}`
44-
45- if (pending.length + text.length > MAX_RUN_CHARS) {
46- children.push(pending)
47- pending = ''
48- }
49-
50- pending += text
51- })
52-
53- return pending === '' ? children : [...children, pending]
54- }
55-
56- function runChildren(run: Types.Run): RenderNode[] {
57- const children: RenderNode[] = []
58- let pending = ''
59-
60- function flush() {
61- if (pending !== '') {
62- children.push(pending)
63- pending = ''
64- }
65- }
66-
67- function plain(text: string) {
68- if (pending.length + text.length > MAX_RUN_CHARS) {
69- flush()
70- }
71-
72- pending += text
73- }
74-
75- function marked(text: string) {
76- flush()
77- children.push(
78- <Text backgroundColor={WORD_BACKGROUNDS[run.kind]}>{text}</Text>,
79- )
80- }
81-
82- run.rows.forEach((row, at) => {
83- const used = row.segments.reduce(
84- (sum, segment) => sum + Layout.chargedWidth(segment.text),
85- MARKER_CELLS,
86- )
87- plain(`${at === 0 ? '' : '\n'} ${row.marker} `)
88-
89- for (const segment of row.segments) {
90- ;(segment.isChanged ? marked : plain)(segment.text)
91- }
92-
93- plain(' '.repeat(Math.max(0, bandCells - used)))
94- })
95- flush()
96-
97- return children
98- }
99-
100- return (
101- <Box flexDirection="row">
102- <Text dimColor wrap="truncate-end">
103- {gutterChildren()}
104- </Text>
105- <Box flexDirection="column" flexShrink={0} width={bandCells}>
106- {runsOf(rows).map(run => (
107- <Text backgroundColor={BACKGROUNDS[run.kind]} wrap="truncate-end">
108- {runChildren(run)}
109- </Text>
110- ))}
111- </Box>
112- </Box>
113- )
114-}
Dmods/diff/hooks/views/detail/hunk-body/index.ts+0 -4
@@ -1,4 +1,0 @@
1-export * from './body-cells'
2-export * from './hunk-body.jsx'
3-
4-export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The dim text between two `Code` blocks that meet at a hunk boundary: the
3+ * ellipsis the engine draws between the hunks of one `Code`.
4+ */
5+export const HUNK_DIVIDER = '...'
@@ -1,0 +1,3 @@
1+export * from './hunk-divider.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * The nodes one HUNK_DIVIDER costs the tree: its Text and its string.
3+ */
4+export const HUNK_DIVIDER_NODES = 2
@@ -1,0 +1,3 @@
1+export * from './hunk-divider-nodes.js'
2+
3+export * as default from '.'
@@ -1,0 +1,23 @@
1+import { countOf } from '../../../count-of'
2+import type Git from '../../../git'
3+
4+/**
5+ * A hunk's `@@ -a,b +c,d @@` header, its counts read off its lines, so a
6+ * cut or split hunk still parses: context counts on both sides.
7+ *
8+ * A side with no lines names the line before its start, as jsdiff's
9+ * formatPatch writes a structured hunk and the engine's parser reads back.
10+ *
11+ * @param hunk the hunk, its lines body lines only
12+ * @returns the header line
13+ */
14+export function hunkHeaderOf(hunk: Git.Hunk) {
15+ const oldLines = countOf(hunk.lines, line => !line.startsWith('+'))
16+ const newLines = countOf(hunk.lines, line => !line.startsWith('-'))
17+ const isOldEmpty = oldLines === 0
18+ const isNewEmpty = newLines === 0
19+ const oldStart = isOldEmpty ? Math.max(0, hunk.oldStart - 1) : hunk.oldStart
20+ const newStart = isNewEmpty ? Math.max(0, hunk.newStart - 1) : hunk.newStart
21+
22+ return `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`
23+}
@@ -1,0 +1,3 @@
1+export * from './hunk-header-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,11 @@
1+import type Git from '../../../git'
2+import { hunkHeaderOf } from '../hunk-header-of'
3+
4+/**
5+ * A hunk as unified-diff text: its header (hunkHeaderOf), then its lines.
6+ *
7+ * @param hunk the hunk, its lines body lines only
8+ * @returns the text, no trailing newline
9+ */
10+export const hunkSourceOf = (hunk: Git.Hunk) =>
11+ [hunkHeaderOf(hunk), ...hunk.lines].join('\n')
@@ -1,0 +1,3 @@
1+export * from './hunk-source-of.js'
2+
3+export * as default from '.'
@@ -1,18 +1,16 @@
1-export * from './backgrounds'
1+export * from './code-blocks-of'
22 export * from './detail-model'
33 export * from './detail-view.jsx'
4-export * from './gutter-cells-of'
5-export * from './hunk-body'
6-export * from './lines-of'
7-export * from './marker-cells'
4+export * from './hunk-divider'
5+export * from './hunk-divider-nodes'
6+export * from './hunk-header-of'
7+export * from './hunk-source-of'
8+export * from './leading-hunk-of'
89 export * from './max-body-chars'
910 export * from './max-body-nodes'
10-export * from './max-rows-of'
11-export * from './max-run-chars'
11+export * from './max-code-chars'
1212 export * from './placeholder-of'
13-export * from './rows-of'
14-export * from './runs-of'
13+export * from './sub-hunks-of'
1514 export * from './types'
16-export * from './word-backgrounds'
1715
1816 export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './leading-hunk-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,32 @@
1+import type Git from '../../../git'
2+import { hunkHeaderOf } from '../hunk-header-of'
3+
4+/**
5+ * The most of a hunk's first lines whose text (hunkSourceOf) fits the
6+ * room, whole lines only: none when the first line alone does not fit.
7+ *
8+ * @param hunk the hunk, its lines body lines only
9+ * @param maxChars the room, in UTF-16 units
10+ * @returns the hunk cut to those lines, its starts unchanged
11+ */
12+export function leadingHunkOf(hunk: Git.Hunk, maxChars: number): Git.Hunk {
13+ let bodyChars = 0
14+ let taken = 0
15+
16+ for (const line of hunk.lines) {
17+ const header = hunkHeaderOf({
18+ ...hunk,
19+ lines: hunk.lines.slice(0, taken + 1),
20+ })
21+
22+ bodyChars += 1 + line.length
23+
24+ if (header.length + bodyChars > maxChars) {
25+ break
26+ }
27+
28+ taken += 1
29+ }
30+
31+ return { ...hunk, lines: hunk.lines.slice(0, taken) }
32+}
Dmods/diff/hooks/views/detail/lines-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './lines-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/lines-of/lines-of.ts+0 -74
@@ -1,74 +1,0 @@
1-import type Git from '../../../git'
2-import Layout from '../../layout'
3-import type Types from '../types'
4-
5-/**
6- * A file's hunks as numbered lines, hunk after hunk: context and added
7- * lines count the new file's lines, removed ones the old file's.
8- *
9- * Each run of removed lines is paired index by index with the added run
10- * right after it, for the word diff, as the engine's fallback diff pairs.
11- *
12- * @param hunks the parsed hunks
13- * @returns the lines in drawing order
14- */
15-export function linesOf(hunks: readonly Git.Hunk[]): Types.DiffLine[] {
16- const lines: Types.DiffLine[] = []
17-
18- for (const hunk of hunks) {
19- let oldNumber = hunk.oldStart
20- let newNumber = hunk.newStart
21- let removedRun: Types.DiffLine[] = []
22- let pairing = 0
23-
24- for (const raw of hunk.lines) {
25- const text = Layout.sanitizeLine(raw.slice(1))
26- const kind: Types.LineKind = raw.startsWith('+')
27- ? 'added'
28- : raw.startsWith('-')
29- ? 'removed'
30- : 'context'
31-
32- if (kind === 'removed') {
33- const isNewRun = pairing > 0
34-
35- if (isNewRun) {
36- removedRun = []
37- pairing = 0
38- }
39-
40- const line = { kind, lineNumber: oldNumber, text, pairedText: null }
41- removedRun.push(line)
42- lines.push(line)
43- oldNumber += 1
44- continue
45- }
46-
47- if (kind === 'added') {
48- const partner = removedRun[pairing]
49-
50- if (partner) {
51- partner.pairedText = text
52- pairing += 1
53- }
54-
55- lines.push({
56- kind,
57- lineNumber: newNumber,
58- text,
59- pairedText: partner ? partner.text : null,
60- })
61- newNumber += 1
62- continue
63- }
64-
65- removedRun = []
66- pairing = 0
67- lines.push({ kind, lineNumber: newNumber, text, pairedText: null })
68- oldNumber += 1
69- newNumber += 1
70- }
71- }
72-
73- return lines
74-}
Dmods/diff/hooks/views/detail/marker-cells/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './marker-cells.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/marker-cells/marker-cells.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The cells between the gutter and a row's content: a space, the `+`, `-`
3- * or space marker, and a space.
4- */
5-export const MARKER_CELLS = 3
@@ -1,5 +1,7 @@
11 /**
2- * The most characters a file's drawn body holds in all, padding included:
3- * under the host's whole-tree cap (100 000) with room for the pane's rest.
2+ * The most characters a file's drawn body holds, its `Code` sources and
3+ * dividers in all.
4+ *
5+ * Under the host's whole-tree cap (100 000), with room for the pane's rest.
46 */
57 export const MAX_BODY_CHARS = 80_000
@@ -1,5 +1,7 @@
11 /**
2- * The most nodes a file's drawn body holds, strings included: under the
3- * host's whole-tree cap (2 000) with room for the list and the pane's rest.
2+ * The most nodes a file's drawn body holds, a `Code` one and a divider two.
3+ *
4+ * Under the host's whole-tree cap (2 000), with room for the list and the
5+ * pane's rest.
46 */
57 export const MAX_BODY_NODES = 1_200
@@ -1,0 +1,3 @@
1+export * from './max-code-chars.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The most characters one `Code` source (or path) holds: the host's cap on
3+ * a leaf's text (10 000), which a hooks module cannot import.
4+ */
5+export const MAX_CODE_CHARS = 10_000
Dmods/diff/hooks/views/detail/max-rows-of/frame-nodes/frame-nodes.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The nodes the body costs before any row: its row Box, the gutter Text
3- * beside it, and the column Box that holds the runs.
4- */
5-export const FRAME_NODES = 3
Dmods/diff/hooks/views/detail/max-rows-of/frame-nodes/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './frame-nodes.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/max-rows-of/highlight-nodes/highlight-nodes.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The nodes one word highlight costs inside a run: the highlighted Text,
3- * its string, and the string the run resumes with.
4- */
5-export const HIGHLIGHT_NODES = 3
Dmods/diff/hooks/views/detail/max-rows-of/highlight-nodes/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './highlight-nodes.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/max-rows-of/index.ts+0 -6
@@ -1,6 +1,0 @@
1-export * from './frame-nodes'
2-export * from './highlight-nodes'
3-export * from './max-rows-of.js'
4-export * from './run-nodes'
5-
6-export * as default from '.'
Dmods/diff/hooks/views/detail/max-rows-of/max-rows-of.ts+0 -59
@@ -1,59 +1,0 @@
1-import Layout from '../../layout'
2-import type { BodyCells } from '../hunk-body/body-cells'
3-import { MARKER_CELLS } from '../marker-cells'
4-import { MAX_BODY_CHARS } from '../max-body-chars'
5-import { MAX_BODY_NODES } from '../max-body-nodes'
6-import { MAX_RUN_CHARS } from '../max-run-chars'
7-import type Types from '../types'
8-import { FRAME_NODES } from './frame-nodes'
9-import { HIGHLIGHT_NODES } from './highlight-nodes'
10-import { RUN_NODES } from './run-nodes'
11-
12-/**
13- * How many of these rows the body may draw under MAX_BODY_CHARS and
14- * MAX_BODY_NODES, counted as the host counts what hunkBody draws.
15- *
16- * A row costs its gutter, marker, segments and padding in UTF-16 units (an
17- * astral letter costs two); a change of kind opens a run (a Text and its
18- * string), a highlight is three nodes, the gutter and long runs split.
19- *
20- * @param rows the laid-out rows, in order
21- * @param cells the gutter's and the content's widths
22- * @returns the count of leading rows whose drawn tree fits both budgets
23- */
24-export function maxRowsOf(rows: readonly Types.Row[], cells: BodyCells) {
25- let chars = 0
26- let nodes = FRAME_NODES
27- let kept = 0
28- let kind: Types.LineKind | undefined
29-
30- for (const row of rows) {
31- let units = 0
32- let charged = 0
33- let highlights = 0
34-
35- for (const segment of row.segments) {
36- units += segment.text.length
37- charged += Layout.chargedWidth(segment.text)
38- highlights += segment.isChanged ? 1 : 0
39- }
40-
41- chars +=
42- cells.gutterCells +
43- 2 +
44- MARKER_CELLS +
45- units +
46- Math.max(0, cells.contentCells - charged)
47- nodes += (row.kind === kind ? 0 : RUN_NODES) + highlights * HIGHLIGHT_NODES
48- kind = row.kind
49- const splits = Math.ceil((2 * chars) / MAX_RUN_CHARS)
50-
51- if (chars > MAX_BODY_CHARS || nodes + splits > MAX_BODY_NODES) {
52- return kept
53- }
54-
55- kept += 1
56- }
57-
58- return kept
59-}
Dmods/diff/hooks/views/detail/max-rows-of/run-nodes/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './run-nodes.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/max-rows-of/run-nodes/run-nodes.ts+0 -4
@@ -1,4 +1,0 @@
1-/**
2- * The nodes a change of line kind opens: a Text and its string.
3- */
4-export const RUN_NODES = 2
Dmods/diff/hooks/views/detail/max-run-chars/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './max-run-chars.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/max-run-chars/max-run-chars.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The most characters one Text string of a run holds: under the host's
3- * per-string cap (10 000), which a hooks module cannot import.
4- */
5-export const MAX_RUN_CHARS = 8_000
@@ -34,6 +34,7 @@
3434
3535 const { isLarge, hunks } = detail.body
3636 const isEmpty = hunks.length === 0
37+
3738 const note = isLarge
3839 ? 'Large file - diff exceeds 1 MB limit'
3940 : isEmpty
@@ -1,7 +1,8 @@
11 /**
2- * A file name safe to paste into a stage command in any shell (POSIX,
3- * PowerShell, cmd): letters and digits of any script and `._/@+-` only.
2+ * A file name safe to paste, unquoted, into a stage command in any shell
3+ * (POSIX, PowerShell, cmd).
44 *
5- * No quote of any kind, no separator and no substitution can ride in it.
5+ * Letters and digits of any script and `._/@+-` only: no quote of any
6+ * kind, no separator and no substitution can ride in it.
67 */
78 export const SAFE_PATHSPEC_PATTERN = /^[\p{L}\p{N}._/@+-]+$/u
Dmods/diff/hooks/views/detail/rows-of/index.ts+0 -5
@@ -1,5 +1,0 @@
1-export * from './max-highlights'
2-export * from './rows-of.js'
3-export * from './segments-of'
4-
5-export * as default from '.'
Dmods/diff/hooks/views/detail/rows-of/max-highlights/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './max-highlights.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/rows-of/max-highlights/max-highlights.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * How many word-diff highlights one body may draw; past it lines draw plain,
3- * keeping the tree under its node cap (each highlight is two nodes).
4- */
5-export const MAX_HIGHLIGHTS = 300
Dmods/diff/hooks/views/detail/rows-of/rows-of.ts+0 -48
@@ -1,48 +1,0 @@
1-import { countOf } from '../../../count-of'
2-import Layout from '../../layout'
3-import type Types from '../types'
4-import { MAX_HIGHLIGHTS } from './max-highlights'
5-import { segmentsOf } from './segments-of'
6-
7-/**
8- * Numbered lines laid into terminal rows: a long line wraps onto
9- * continuation rows with an empty gutter.
10- *
11- * Word highlights stop once MAX_HIGHLIGHTS are spent, keeping the tree
12- * under its node cap.
13- *
14- * @param lines the numbered lines
15- * @param cells the content's room per row after gutter and marker, one at
16- * least
17- * @returns the rows in drawing order
18- */
19-export function rowsOf(
20- lines: readonly Types.DiffLine[],
21- cells: number,
22-): Types.Row[] {
23- const rows: Types.Row[] = []
24- let highlights = 0
25-
26- for (const line of lines) {
27- const marked = segmentsOf(line)
28- const wanted = countOf(marked, segment => segment.isChanged)
29- const isAffordable = highlights + wanted <= MAX_HIGHLIGHTS
30- highlights += isAffordable ? wanted : 0
31- const segments = isAffordable
32- ? marked
33- : [{ text: line.text, isChanged: false }]
34- const marker =
35- line.kind === 'added' ? '+' : line.kind === 'removed' ? '-' : ' '
36-
37- Layout.wrapSegments(segments, cells).forEach((wrapped, at) => {
38- rows.push({
39- kind: line.kind,
40- gutter: at === 0 ? String(line.lineNumber) : '',
41- marker: at === 0 ? marker : ' ',
42- segments: wrapped,
43- })
44- })
45- }
46-
47- return rows
48-}
Dmods/diff/hooks/views/detail/rows-of/segments-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './segments-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/rows-of/segments-of/segments-of.ts+0 -32
@@ -1,32 +1,0 @@
1-import { wordDiff } from '../../../../word-diff'
2-import type Layout from '../../../layout'
3-import type Types from '../../types'
4-
5-/**
6- * A line's content as segments: one plain segment, or, for a paired line
7- * whose word diff holds, its own side's parts with the changed ones marked.
8- *
9- * @param line the numbered line
10- * @returns the segments in order
11- */
12-export function segmentsOf(line: Types.DiffLine): Layout.Segment[] {
13- const plain = [{ text: line.text, isChanged: false }]
14-
15- if (line.pairedText === null || line.kind === 'context') {
16- return plain
17- }
18-
19- const isRemoved = line.kind === 'removed'
20- const parts = isRemoved
21- ? wordDiff(line.text, line.pairedText)
22- : wordDiff(line.pairedText, line.text)
23- const ownSide = isRemoved ? 'removed' : 'added'
24-
25- if (!parts) {
26- return plain
27- }
28-
29- return parts
30- .filter(part => part.kind === 'same' || part.kind === ownSide)
31- .map(part => ({ text: part.text, isChanged: part.kind === ownSide }))
32-}
Dmods/diff/hooks/views/detail/runs-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './runs-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/runs-of/runs-of.ts+0 -23
@@ -1,23 +1,0 @@
1-import type Types from '../types'
2-
3-/**
4- * Rows grouped into runs of one kind, in order.
5- *
6- * @param rows the laid-out rows
7- * @returns the runs
8- */
9-export function runsOf(rows: readonly Types.Row[]): Types.Run[] {
10- const runs: Types.Run[] = []
11- let start = 0
12-
13- rows.forEach((row, at) => {
14- const isRunEnd = rows[at + 1]?.kind !== row.kind
15-
16- if (isRunEnd) {
17- runs.push({ kind: row.kind, rows: rows.slice(start, at + 1) })
18- start = at + 1
19- }
20- })
21-
22- return runs
23-}
@@ -1,0 +1,24 @@
1+import type Git from '../../../../git'
2+import { hunkHeaderOf } from '../../hunk-header-of'
3+
4+/**
5+ * A hunk's first line cut so that it alone, under its header, fits the
6+ * room: its marker kept, no surrogate pair split.
7+ *
8+ * @param hunk the hunk, its first line too long for a sub-hunk of its own
9+ * @param maxChars the room the one-line sub-hunk's text fits
10+ * @returns the cut line
11+ */
12+export function firstLineCut(hunk: Git.Hunk, maxChars: number) {
13+ const [first = ''] = hunk.lines
14+
15+ const kept = first.slice(
16+ 0,
17+ Math.max(
18+ 1,
19+ maxChars - 1 - hunkHeaderOf({ ...hunk, lines: [first] }).length,
20+ ),
21+ )
22+
23+ return /[\uD800-\uDBFF]$/u.test(kept) ? kept.slice(0, -1) : kept
24+}
@@ -1,0 +1,3 @@
1+export * from './first-line-cut.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './first-line-cut'
2+export * from './sub-hunks-of.js'
3+
4+export * as default from '.'
@@ -1,0 +1,46 @@
1+import { countOf } from '../../../count-of'
2+import type Git from '../../../git'
3+import { leadingHunkOf } from '../leading-hunk-of'
4+import { firstLineCut } from './first-line-cut'
5+
6+/**
7+ * A hunk as consecutive sub-hunks whose texts (hunkSourceOf) each fit the
8+ * room, each starting where the last left off, so the counts add up.
9+ *
10+ * A line too long for a sub-hunk of its own is cut to fit, not dropped, and
11+ * `isTruncated` says so; a hunk that fits comes back whole.
12+ *
13+ * @param hunk the hunk, its lines body lines only
14+ * @param maxChars the room each sub-hunk's text fits, in UTF-16 units
15+ * @returns the sub-hunks in order, and whether a line was cut
16+ */
17+export function subHunksOf(
18+ hunk: Git.Hunk,
19+ maxChars: number,
20+): Pick<Git.FileHunks, 'hunks' | 'isTruncated'> {
21+ const hunks: Git.Hunk[] = []
22+ let rest = hunk
23+ let isTruncated = false
24+
25+ while (rest.lines.length > 0) {
26+ const leading = leadingHunkOf(rest, maxChars)
27+ const isLineCut = leading.lines.length === 0
28+
29+ const piece = isLineCut
30+ ? { ...rest, lines: [firstLineCut(rest, maxChars)] }
31+ : leading
32+
33+ const taken = rest.lines.slice(0, piece.lines.length)
34+
35+ hunks.push(piece)
36+ isTruncated ||= isLineCut
37+
38+ rest = {
39+ oldStart: rest.oldStart + countOf(taken, line => !line.startsWith('+')),
40+ newStart: rest.newStart + countOf(taken, line => !line.startsWith('-')),
41+ lines: rest.lines.slice(piece.lines.length),
42+ }
43+ }
44+
45+ return { hunks, isTruncated }
46+}
@@ -1,0 +1,8 @@
1+/**
2+ * One `Code` element of a file's body: its unified-diff source, and whether
3+ * a divider stands above it (it opens a hunk after another block).
4+ */
5+export type CodeBlock = {
6+ source: string
7+ hasDivider: boolean
8+}
@@ -1,0 +1,3 @@
1+export type * from './code-block.js'
2+
3+export * as default from '.'
@@ -1,0 +1,10 @@
1+import type { CodeBlock } from '../code-block'
2+
3+/**
4+ * A file's hunks as the body draws them: the `Code` blocks in order, and
5+ * whether the body's budget or a source's cap left any of a line out.
6+ */
7+export type CodeBody = {
8+ blocks: readonly CodeBlock[]
9+ isTruncated: boolean
10+}
@@ -1,0 +1,3 @@
1+export type * from './code-body.js'
2+
3+export * as default from '.'
Dmods/diff/hooks/views/detail/types/diff-line/diff-line.ts+0 -15
@@ -1,15 +1,0 @@
1-import type { LineKind } from '../line-kind'
2-
3-/**
4- * One hunk line ready to lay out: its side, its gutter's line number, its
5- * sanitized text, and the paired line's text for a word diff, if any.
6- *
7- * The number is the new file's for context and added lines, the old
8- * file's for removed ones; a removed line pairs with its added counterpart.
9- */
10-export type DiffLine = {
11- kind: LineKind
12- lineNumber: number
13- text: string
14- pairedText: string | null
15-}
Dmods/diff/hooks/views/detail/types/diff-line/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './diff-line.js'
2-
3-export * as default from '.'
@@ -1,6 +1,4 @@
1-export * from './diff-line'
2-export * from './line-kind'
3-export * from './row'
4-export type * from './run.js'
1+export * from './code-block'
2+export * from './code-body'
53
64 export * as default from '.'
Dmods/diff/hooks/views/detail/types/line-kind/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './line-kind.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/types/line-kind/line-kind.ts+0 -4
@@ -1,4 +1,0 @@
1-/**
2- * Which side of the diff a line is on, read from its marker.
3- */
4-export type LineKind = 'context' | 'added' | 'removed'
Dmods/diff/hooks/views/detail/types/row/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './row.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/types/row/row.ts+0 -13
@@ -1,13 +1,0 @@
1-import type Layout from '../../../layout'
2-import type { LineKind } from '../line-kind'
3-
4-/**
5- * One terminal row of a file's body: the gutter's text (the line number,
6- * or nothing on a continuation), the marker, and the content's segments.
7- */
8-export type Row = {
9- kind: LineKind
10- gutter: string
11- marker: string
12- segments: readonly Layout.Segment[]
13-}
Dmods/diff/hooks/views/detail/types/run.ts+0 -11
@@ -1,11 +1,0 @@
1-import type { LineKind } from './line-kind'
2-import type { Row } from './row'
3-
4-/**
5- * Consecutive rows of one kind, drawn as one Text so a long file stays far
6- * under the tree's node cap.
7- */
8-export type Run = {
9- kind: LineKind
10- rows: readonly Row[]
11-}
Dmods/diff/hooks/views/detail/word-backgrounds/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './word-backgrounds.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/word-backgrounds/word-backgrounds.ts+0 -13
@@ -1,13 +1,0 @@
1-import type Types from '../types'
2-
3-/**
4- * The theme key behind a changed word of each kind; a context line never
5- * carries one, so it has none.
6- */
7-export const WORD_BACKGROUNDS: Readonly<
8- Record<Types.LineKind, string | undefined>
9-> = {
10- added: 'diffAddedWord',
11- removed: 'diffRemovedWord',
12- context: undefined,
13-}
@@ -1,5 +1,6 @@
11 export * from './current-pane'
22 export * from './detail'
3+export * from './inset-of'
34 export * from './kit'
45 export * from './layout'
56 export * from './pane-actions'
@@ -1,0 +1,3 @@
1+export * from './inset-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+import Limits from '../../limits'
2+import type { Kit } from '../kit'
3+
4+/**
5+ * A drawing's kit inside the docked pane's padding: the built-in's blank
6+ * last column and its blank row above the header taken off.
7+ *
8+ * @param kit the drawing's kit at the pane's full size
9+ * @returns the kit its body draws with
10+ */
11+export const insetOf = (kit: Kit): Kit => ({
12+ ...kit,
13+ columns: Math.max(1, kit.columns - Limits.PANE_RIGHT_PAD_COLUMNS),
14+ rows: Math.max(0, kit.rows - Limits.PANE_TOP_PAD_ROWS),
15+})
@@ -2,11 +2,14 @@
22 import type { Ui } from './ui'
33
44 /**
5- * What every part of one drawing is handed: the surface's elements, the
6- * controls' handlers, and the body's width (`props.bodyColumns`).
5+ * What every part of one drawing is handed.
6+ *
7+ * The surface's elements, the controls' handlers, the body's width
8+ * (`props.bodyColumns`) and its rows (`props.scroll.bodyRows`).
79 */
810 export type Kit = {
911 ui: Ui
1012 actions: PaneActions
1113 columns: number
14+ rows: number
1215 }
@@ -4,10 +4,10 @@
44 * The element constructors the pane draws with, destructured from the
55 * table `$.ui.resolve(e)` hands the render hook.
66 *
7- * The terminal and the desktop carry all four; the mobile app has no
7+ * The terminal and the desktop carry all five; the mobile app has no
88 * Select, so the pane never draws there.
99 */
1010 export type Ui = Pick<
1111 ElementTable<'terminal' | 'desktop'>,
12- 'Box' | 'Text' | 'Button' | 'Select'
12+ 'Box' | 'Text' | 'Button' | 'Select' | 'Code'
1313 >
@@ -17,8 +17,10 @@
1717
1818 for (const character of text) {
1919 const point = character.codePointAt(0) ?? 0
20+
2021 const holds = ([low, high]: readonly [number, number]) =>
2122 point >= low && point <= high
23+
2224 const isNarrow = point < FIRST_WIDE_POINT
2325 const isZero = !isNarrow && ZERO_WIDTH_RANGES.some(holds)
2426 const isWide = !isNarrow && !isZero && WIDE_RANGES.some(holds)
@@ -3,10 +3,8 @@
33 export * from './plural'
44 export * from './sanitize-line'
55 export * from './sanitize-name'
6-export * from './segment'
7-export * from './slice-cells'
86 export * from './tab-width'
97 export * from './truncate-start'
10-export * from './wrap-segments'
8+export * from './wrapped-lines'
119
1210 export * as default from '.'
Dmods/diff/hooks/views/layout/segment/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './segment.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/layout/segment/segment.ts+0 -8
@@ -1,8 +1,0 @@
1-/**
2- * One stretch of a drawn diff row: its text and whether the word diff
3- * marks it as changed.
4- */
5-export type Segment = {
6- text: string
7- isChanged: boolean
8-}
Dmods/diff/hooks/views/layout/slice-cells/cells-split/cells-split.ts+0 -9
@@ -1,9 +1,0 @@
1-/**
2- * A line cut at a cell budget: the head that fits, the budget it spent
3- * (a code point that draws in no cell still spends one), and the tail.
4- */
5-export type CellsSplit = {
6- head: string
7- spent: number
8- tail: string
9-}
Dmods/diff/hooks/views/layout/slice-cells/cells-split/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './cells-split.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/layout/slice-cells/index.ts+0 -4
@@ -1,4 +1,0 @@
1-export * from './cells-split'
2-export * from './slice-cells.js'
3-
4-export * as default from '.'
Dmods/diff/hooks/views/layout/slice-cells/slice-cells.ts+0 -37
@@ -1,37 +1,0 @@
1-import { chargedWidth } from '../charged-width'
2-import type { CellsSplit } from './cells-split'
3-
4-/**
5- * The longest head of a line that fits a cell budget, cut between code
6- * points, and what is left after it.
7- *
8- * The budget is spent by chargedWidth, at least one cell a code point, so
9- * a head never holds more code points than cells: a line of ten thousand
10- * combining marks still wraps.
11- *
12- * @param text a sanitized line
13- * @param cells the room, at least one cell
14- * @returns the head that fits, the budget it spent, and the remaining tail
15- */
16-export function sliceCells(text: string, cells: number): CellsSplit {
17- let spent = 0
18- let taken = 0
19- const characters = [...text]
20-
21- for (const character of characters) {
22- const next = spent + chargedWidth(character)
23-
24- if (next > cells && taken > 0) {
25- break
26- }
27-
28- spent = next
29- taken += 1
30- }
31-
32- return {
33- head: characters.slice(0, taken).join(''),
34- spent,
35- tail: characters.slice(taken).join(''),
36- }
37-}
Dmods/diff/hooks/views/layout/wrap-segments/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './wrap-segments.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/layout/wrap-segments/wrap-segments.ts+0 -43
@@ -1,43 +1,0 @@
1-import type { Segment } from '../segment'
2-import { sliceCells } from '../slice-cells'
3-
4-/**
5- * A line's segments laid into rows of at most a cell budget, a segment
6- * split where a row fills, so each row draws as exactly one terminal row.
7- *
8- * A row holds no more code points than cells (sliceCells' spending), and
9- * a continuation can sit under an empty gutter.
10- *
11- * @param segments the line's segments in order
12- * @param cells the room per row, at least one cell
13- * @returns the rows; one empty row for an empty line
14- */
15-export function wrapSegments(
16- segments: readonly Segment[],
17- cells: number,
18-): Segment[][] {
19- const rows: Segment[][] = []
20- let row: Segment[] = []
21- let used = 0
22-
23- for (const segment of segments) {
24- let rest = segment.text
25-
26- while (rest !== '') {
27- const split = sliceCells(rest, Math.max(1, cells - used))
28- row.push({ text: split.head, isChanged: segment.isChanged })
29- used += split.spent
30- rest = split.tail
31-
32- if (rest !== '' || used >= cells) {
33- rows.push(row)
34- row = []
35- used = 0
36- }
37- }
38- }
39-
40- const isRowPending = row.length > 0 || rows.length === 0
41-
42- return isRowPending ? [...rows, row] : rows
43-}
@@ -1,0 +1,3 @@
1+export * from './wrapped-lines.js'
2+
3+export * as default from '.'
@@ -1,0 +1,73 @@
1+import { cellWidth } from '../cell-width'
2+
3+/**
4+ * A line cut into rows as Ink's `wrap` cuts the built-in's empty state
5+ * before it centers each row.
6+ *
7+ * It cuts at spaces, keeping the space a row breaks on at its end; a word
8+ * wider than a row is cut across rows.
9+ *
10+ * @param text one sanitized line
11+ * @param columns the width, at least one cell; no row holds more
12+ * @returns the rows, at least one
13+ */
14+export function wrappedLines(text: string, columns: number): string[] {
15+ const width = Math.max(1, columns)
16+ const rows: string[] = ['']
17+ const words = text.split(' ')
18+
19+ for (const [index, word] of words.entries()) {
20+ const wordWidth = cellWidth(word)
21+ let rowWidth = cellWidth(rows[rows.length - 1] ?? '')
22+
23+ if (index !== 0) {
24+ if (rowWidth >= width) {
25+ rows.push('')
26+ rowWidth = 0
27+ }
28+
29+ rows[rows.length - 1] += ' '
30+ rowWidth += 1
31+ }
32+
33+ if (wordWidth > width) {
34+ const breaksHere =
35+ 1 + Math.floor((wordWidth - (width - rowWidth) - 1) / width)
36+
37+ if (Math.floor((wordWidth - 1) / width) < breaksHere) {
38+ rows.push('')
39+ }
40+
41+ const characters = [...word]
42+ let visible = cellWidth(rows[rows.length - 1] ?? '')
43+
44+ for (const [at, character] of characters.entries()) {
45+ const characterWidth = cellWidth(character)
46+
47+ if (visible + characterWidth <= width) {
48+ rows[rows.length - 1] += character
49+ } else {
50+ rows.push(character)
51+ visible = 0
52+ }
53+
54+ visible += characterWidth
55+
56+ if (visible === width && at < characters.length - 1) {
57+ rows.push('')
58+ visible = 0
59+ }
60+ }
61+
62+ continue
63+ }
64+
65+ if (rowWidth + wordWidth > width && rowWidth > 0 && wordWidth > 0) {
66+ rows.push('')
67+ }
68+
69+ rows[rows.length - 1] += word
70+ }
71+
72+ return rows
73+}
@@ -33,4 +33,10 @@
3333 * Arms this file's diff for the next prompt, or disarms it.
3434 */
3535 toggleAsk: (path: string) => void
36+
37+ /**
38+ * Closes the pane as `/diff` closes it, the choice kept for the next
39+ * first edit.
40+ */
41+ close: () => void
3642 }
@@ -3,22 +3,50 @@
33 /* @jsxFrag Fragment */
44 import type { RenderElement } from 'claude-code'
55
6+import Limits from '../limits'
7+import Names from '../names'
68 import PaneState from '../pane-state'
79 import { currentPane } from './current-pane'
10+import { insetOf } from './inset-of'
811 import type { Kit } from './kit'
12+import Sections from './sections'
913 import { turnPane } from './turn-pane'
1014
1115 /**
1216 * The diff pane's body for one `ui.render`: the repository's diff now, or
1317 * the picked turn's edits while that turn still exists (a rewind drops it).
1418 *
19+ * Seated inline, only the built-in's wider-terminal line, as it shows no
20+ * panel there; docked, the built-in's blank row above the header and blank
21+ * last column, so rows sit and wrap where the built-in's do.
22+ *
1523 * @param kit the elements, the handlers, the width
1624 * @param model the pane's state
25+ * @param placement where the surface seated the pane (`props.placement`)
1726 * @returns the tree
1827 */
19-export function paneView(kit: Kit, model: PaneState.PaneModel): RenderElement {
28+export function paneView(
29+ kit: Kit,
30+ model: PaneState.PaneModel,
31+ placement: 'dock' | 'inline',
32+): RenderElement {
33+ const { Box } = kit.ui
2034 const turn = PaneState.pickedTurnOf(model)
2135 const isCurrent = turn === undefined
2236
23- return isCurrent ? currentPane(kit, model) : turnPane(kit, model, turn)
37+ if (placement === 'inline') {
38+ return <Box>{Sections.dimNote(kit, Names.RESIZE_TERMINAL_TEXT)}</Box>
39+ }
40+
41+ const inset = insetOf(kit)
42+
43+ return (
44+ <Box
45+ flexDirection="column"
46+ paddingTop={Limits.PANE_TOP_PAD_ROWS}
47+ paddingRight={Limits.PANE_RIGHT_PAD_COLUMNS}
48+ >
49+ {isCurrent ? currentPane(inset, model) : turnPane(inset, model, turn)}
50+ </Box>
51+ )
2452 }
@@ -1,0 +1,33 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import type { Kit } from '../../kit'
7+
8+/**
9+ * The pane's close control (ReplDiffSidebar's PanelCloseButton): a `✕` at
10+ * the header's right edge that closes the pane as `/diff` does.
11+ *
12+ * Bold under the pointer, as the built-in's is. The built-in's rest style,
13+ * dim, is not a Button's to take: its label's only style is `hover`.
14+ *
15+ * @param kit the drawing's kit; its handlers close the pane
16+ * @returns the control, in the keyed Box its hover needs
17+ */
18+export function closeButton(kit: Kit): RenderElement {
19+ const { Box, Button } = kit.ui
20+
21+ return (
22+ <Box key="close">
23+ <Button
24+ key="close"
25+ plain
26+ hover={{ bold: true }}
27+ onPress={kit.actions.close}
28+ >
29+ {'✕'}
30+ </Button>
31+ </Box>
32+ )
33+}
@@ -1,0 +1,3 @@
1+export * from './close-button.jsx'
2+
3+export * as default from '.'
@@ -12,9 +12,8 @@
1212 * turn with edits, newest first) and, for the current source, the base.
1313 *
1414 * The source picker draws only when a turn exists, as DiffDialog hides its
15- * tabs; the base picker is the built-in's ctrl+x b cycle as a Select over
16- * the modes the backend offers, uncommitted mode naming the base as the
17- * base line does (modeLabelOf).
15+ * tabs; the base picker is ctrl+x b's cycle as a Select over the backend's
16+ * modes, uncommitted naming the base as the base line does (modeLabelOf).
1817 *
1918 * @param kit the elements and the handlers
2019 * @param model what is picked now and the turns to pick from
@@ -30,6 +29,7 @@
3029 const { Box, Select } = kit.ui
3130 const { source, turns } = model
3231 const hasTurns = turns.length > 0
32+
3333 const sourcePicker = hasTurns ? (
3434 <Select
3535 key="source"
@@ -45,30 +45,35 @@
4545 onSelect={value => kit.actions.chooseSource(value)}
4646 />
4747 ) : null
48+
4849 const isCurrent = source.kind === 'current'
4950 const fetchedSource = model.data?.source
50- const base =
51- fetchedSource?.kind === 'working-tree'
52- ? fetchedSource.base
53- : model.words.base
51+ const isWorkingTree = fetchedSource?.kind === 'working-tree'
52+ const base = isWorkingTree ? fetchedSource.base : model.words.base
53+
5454 const basePicker = isCurrent ? (
5555 <Select
5656 key="base"
5757 label="base"
58- options={model.baseModes.map(mode => ({
59- value: mode,
60- label:
61- mode === 'uncommitted'
62- ? `uncommitted (vs ${base})`
63- : mode === 'session'
64- ? 'this session'
65- : mode,
66- }))}
58+ options={model.baseModes.map(mode => {
59+ const isUncommitted = mode === 'uncommitted'
60+ const isSession = mode === 'session'
61+
62+ const label = isUncommitted
63+ ? `uncommitted (vs ${base})`
64+ : isSession
65+ ? 'this session'
66+ : mode
67+
68+ return { value: mode, label }
69+ })}
6770 value={model.requestedMode}
6871 onSelect={value => kit.actions.chooseBase(value)}
6972 />
7073 ) : null
74+
7175 const pickers = present([sourcePicker, basePicker])
76+
7277 const row = (
7378 <Box flexDirection="row" gap={2}>
7479 {pickers}
@@ -6,7 +6,8 @@
66 import type { Kit } from '../kit'
77
88 /**
9- * A dim rule across the body, between the list and the selected file.
9+ * A dim rule across the body: between the list and the selected file, and
10+ * under that file's name.
1011 *
1112 * @param kit the elements and the width
1213 * @returns the rule
@@ -29,11 +29,13 @@
2929 const { Box, Text, Button } = kit.ui
3030 const room = Math.max(kit.columns - STAT_CELLS, STAT_CELLS)
3131 const mark = row.isSelected ? POINTER : ' '
32+
3233 const note = (
3334 <Text dimColor italic>
3435 {row.note ?? ''}
3536 </Text>
3637 )
38+
3739 const hasNote = row.note !== null
3840 const tail = hasNote ? note : diffStat(kit, row.added, row.removed)
3941
@@ -6,38 +6,40 @@
66 import type PaneState from '../../pane-state'
77 import type { Kit } from '../kit'
88 import Layout from '../layout'
9+import { closeButton } from './close-button'
910 import { diffStat } from './diff-stat'
1011
1112 /**
12- * The pane's first line: `N files changed +A -R`, or the empty headline in
13- * its place when there is nothing to count (ReplDiffSidebar's header).
13+ * The pane's first line (ReplDiffSidebar's header row): `N files changed
14+ * +A -R`, or nothing over an empty state.
1415 *
15- * The headline may name a branch the repository chose, so it is drawn as
16- * a name: no control or format character survives.
16+ * The close `✕` sits at the right edge either way.
1717 *
1818 * @param kit the drawing's kit; its elements draw the line
19- * @param headline the empty headline, or null to draw the counts
20- * @param totals the header's counts
19+ * @param totals the header's counts, or null when there is nothing to count
2120 * @returns the line
2221 */
2322 export function headerView(
2423 kit: Kit,
25- headline: string | null,
26- totals: PaneState.HeaderTotals,
24+ totals: PaneState.HeaderTotals | null,
2725 ): RenderElement {
28- const { Text } = kit.ui
29- const empty = (
30- <Text dimColor wrap="truncate-end">
31- {Layout.sanitizeName(headline ?? '')}
32- </Text>
33- )
34- const counts = (
35- <Text wrap="truncate-end">
36- <Text bold>{Layout.plural(totals.filesCount, 'file')}</Text>
37- {' changed '}
38- {diffStat(kit, totals.linesAdded, totals.linesRemoved)}
39- </Text>
40- )
26+ const { Box, Text } = kit.ui
4127
42- return headline === null ? counts : empty
28+ return (
29+ <Box flexDirection="row">
30+ {[
31+ ...(totals
32+ ? [
33+ <Text wrap="truncate-end">
34+ <Text bold>{Layout.plural(totals.filesCount, 'file')}</Text>
35+ {' changed '}
36+ {diffStat(kit, totals.linesAdded, totals.linesRemoved)}
37+ </Text>,
38+ ]
39+ : []),
40+ <Box flexGrow={1} />,
41+ closeButton(kit),
42+ ]}
43+ </Box>
44+ )
4345 }
@@ -1,3 +1,4 @@
1+export * from './close-button'
12 export * from './controls-view.jsx'
23 export * from './diff-stat'
34 export * from './dim-note'
@@ -3,7 +3,7 @@
33 /* @jsxFrag Fragment */
44 import type { RenderElement } from 'claude-code'
55
6-import type Todos from '../../todos'
6+import type PaneState from '../../pane-state'
77 import type { Kit } from '../kit'
88 import { TODO_BAR_CELLS } from './todo-bar-cells'
99 import { TODO_BAR_RESERVE } from './todo-bar-reserve'
@@ -13,22 +13,26 @@
1313 * a todo list exists (ReplDiffSidebar's ProgressBar row).
1414 *
1515 * @param kit the elements and the width
16- * @param todos completed over total
16+ * @param model the pane's state, holding its todos' completed over total
1717 * @returns the row, or null without todos
1818 */
1919 export function todoBar(
2020 kit: Kit,
21- todos: Todos.TodoProgress,
21+ model: Pick<PaneState.PaneModel, 'todos'>,
2222 ): RenderElement | null {
23+ const { todos } = model
24+
2325 if (todos.total === 0) {
2426 return null
2527 }
2628
2729 const { Box, Text } = kit.ui
30+
2831 const cells = Math.max(
2932 1,
3033 Math.min(TODO_BAR_CELLS, kit.columns - TODO_BAR_RESERVE),
3134 )
35+
3236 const filled = Math.round((todos.done / todos.total) * cells)
3337
3438 return (
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/change-threshold/change-threshold.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/change-threshold/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/diff-parts/diff-parts.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/diff-parts/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/max-lcs-cells/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/max-lcs-cells/max-lcs-cells.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/tokens-of/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/tokens-of/tokens-of.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/word-diff.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/word-part/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/word-diff/word-part/word-part.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/choice/choice.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/choice/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/log-entry/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/log-entry/log-entry.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/mark-entry/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/mark-entry/mark-entry.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/mark-kind/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/mark-kind/mark-kind.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/method.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/prop/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/prop/prop.ts
Diff not shown: this commit changes too much to display at once.
Dmods/telemetry/hooks/telemetry-types/telemetry.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.