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 #93244 from anthropics/poteat/mods-telemetry-fixesBrowse files
mods: API renames, telemetry fixes, and a diff backend seam
Alice T'Poteat authored on Sep 10, 2026 · GitHub committed on Sep 10, 2026 parents e62465d + 1297731 · commit fb63c4ad0161
61 changed files+646 -258compared with e62465d
Amods/diff/hooks/backend/backend-of.ts
Amods/diff/hooks/backend/index.ts
Amods/diff/hooks/backend/installed-backend-probes.ts
Amods/diff/hooks/backend/types/backend/backend.ts
Amods/diff/hooks/backend/types/backend/index.ts
Amods/diff/hooks/backend/types/backend-host/backend-host.ts
Amods/diff/hooks/backend/types/backend-host/index.ts
Amods/diff/hooks/backend/types/backend-probe/backend-probe.ts
Amods/diff/hooks/backend/types/backend-probe/index.ts
Amods/diff/hooks/backend/types/backend-words/backend-words.ts
Amods/diff/hooks/backend/types/backend-words/index.ts
Amods/diff/hooks/backend/types/index.ts
Amods/diff/hooks/git/failed-run/failed-run.ts
Amods/diff/hooks/git/failed-run/index.ts
Amods/diff/hooks/git/git-backend-of.ts
Amods/diff/hooks/git/git-base-modes/git-base-modes.ts
Amods/diff/hooks/git/git-base-modes/index.ts
Amods/diff/hooks/git/git-words/git-words.ts
Amods/diff/hooks/git/git-words/index.ts
Mmods/diff/hooks/git/index.ts
Mmods/diff/hooks/git/parse/is-whole-listing/is-whole-listing.ts
Mmods/diff/hooks/git/probes/index.ts
Mmods/diff/hooks/git/probes/tag-pre-session.ts
Mmods/diff/hooks/git/probes/untracked-files/untracked-files.ts
Amods/diff/hooks/git/probes/untracked-rows-of/index.ts
Amods/diff/hooks/git/probes/untracked-rows-of/untracked-rows-of.ts
Mmods/diff/hooks/git/tiers/branch-source-of/branch-source-of.ts
Mmods/diff/hooks/git/tiers/unborn-tier/unborn-tier.ts
Mmods/diff/hooks/git/tiers/with-untracked/index.ts
Amods/diff/hooks/git/tiers/with-untracked/merged-with-untracked/index.ts
Amods/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-basis/working-tree-basis.ts
Amods/diff/hooks/git/types/dating-context/dating-context.ts
Amods/diff/hooks/git/types/dating-context/index.ts
Mmods/diff/hooks/git/types/diff-data/diff-data.ts
Mmods/diff/hooks/git/types/diff-source/diff-source.ts
Mmods/diff/hooks/git/types/index.ts
Mmods/diff/hooks/index.ts
Mmods/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/initial-model/initial-model.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/register.ts
Mmods/diff/hooks/todos/todo-progress-of.ts
Mmods/diff/hooks/views/current-pane/current-pane.tsx
Mmods/diff/hooks/views/detail/backgrounds/backgrounds.ts
Mmods/diff/hooks/views/detail/detail-model/detail-model.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
Mmods/diff/hooks/views/detail/word-backgrounds/word-backgrounds.ts
Mmods/diff/hooks/views/sections/controls-view.tsx
Mmods/diff/hooks/views/turn-pane/turn-pane.tsx
Mmods/telemetry/README.md
Mmods/telemetry/hooks/environment/environment.ts
Mmods/telemetry/hooks/is-analytics-off/is-analytics-off.ts
Mmods/telemetry/hooks/register.ts
Mmods/telemetry/hooks/telemetry-deps/telemetry-deps.ts
Mmods/telemetry/hooks/telemetry-of/telemetry-of.ts
@@ -1,0 +1,29 @@
1+import Git from '../git'
2+import type { Backend, BackendHost, BackendProbe } from './types'
3+
4+/**
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
9+ * should govern.
10+ *
11+ * @param host the bound host's runner and probes
12+ * @param probes probes for other version-control systems, in order
13+ * (INSTALLED_BACKEND_PROBES at the pane; none as published)
14+ * @returns the pinned backend, or null outside any working tree
15+ */
16+export async function backendOf(
17+ host: BackendHost,
18+ probes: Iterable<BackendProbe>,
19+): Promise<Backend | null> {
20+ for (const probe of probes) {
21+ const backend = await probe(host)
22+
23+ if (backend) {
24+ return backend
25+ }
26+ }
27+
28+ return Git.gitBackendOf(host)
29+}
@@ -1,0 +1,5 @@
1+export * from './backend-of.js'
2+export * from './installed-backend-probes.js'
3+export * from './types'
4+
5+export * as default from '.'
@@ -1,0 +1,15 @@
1+import type { BackendProbe } from './types'
2+
3+/**
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.
13+ */
14+// 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
15+export const INSTALLED_BACKEND_PROBES = new Set<BackendProbe>()
@@ -1,0 +1,46 @@
1+import type Git from '../../../git'
2+import type { BackendWords } from '../backend-words'
3+
4+/**
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.
8+ *
9+ * Git is the plugin's own backend; a host may install probes for others,
10+ * consulted where no git working tree holds the directory (backendOf).
11+ */
12+export type Backend = {
13+ /**
14+ * The working tree's top; every row's path is relative to it.
15+ */
16+ repository: Pick<Git.Repository, 'toplevel'>
17+
18+ /**
19+ * The base modes the picker offers, in picker order.
20+ */
21+ baseModes: readonly Git.BaseMode[]
22+
23+ /**
24+ * The pane's words for what this backend compares and runs.
25+ */
26+ words: BackendWords
27+
28+ /**
29+ * One fetch of the working tree against the mode's base (Git.fetchDiff).
30+ */
31+ fetchDiff: (mode: Git.BaseMode) => Promise<Git.FetchOutcome>
32+
33+ /**
34+ * One row's hunks against the base its fetch read (Git.fetchFileHunks).
35+ */
36+ fetchFileHunks: (
37+ data: Git.DiffData,
38+ file: Git.FileStat,
39+ ) => Promise<Git.FileHunks | null>
40+
41+ /**
42+ * A string that changes when the checked-out commit moves
43+ * (Git.headKeyOf); empty when it cannot be read this tick.
44+ */
45+ headKeyOf: () => Promise<string>
46+}
@@ -1,0 +1,3 @@
1+export type * from './backend.js'
2+
3+export * as default from '.'
@@ -1,0 +1,22 @@
1+import type Git from '../../../git'
2+import type Host from '../../../host'
3+
4+/**
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.
10+ */
11+export type BackendHost = Pick<Host.Host, 'run' | 'readFile'> &
12+ Pick<Git.GitDeps, 'mtimeOf' | 'entryKindsOf' | 'onBranchBase'> & {
13+ /**
14+ * The engine's clock, in milliseconds (`$.clock.now`).
15+ */
16+ nowMs: () => number
17+
18+ /**
19+ * When the current session began, read at each fetch.
20+ */
21+ sessionStartMsOf: () => number
22+ }
@@ -1,0 +1,3 @@
1+export type * from './backend-host.js'
2+
3+export * as default from '.'
@@ -1,0 +1,9 @@
1+import type { Backend } from '../backend'
2+import type { BackendHost } from '../backend-host'
3+
4+/**
5+ * 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).
8+ */
9+export type BackendProbe = (host: BackendHost) => Promise<Backend | null>
@@ -1,0 +1,3 @@
1+export type * from './backend-probe.js'
2+
3+export * as default from '.'
@@ -1,0 +1,27 @@
1+/**
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.
5+ */
6+export type BackendWords = {
7+ /**
8+ * What the working tree is compared with outside branch mode: `HEAD`.
9+ */
10+ base: string
11+
12+ /**
13+ * The command a failed fetch could not read: `git diff`.
14+ */
15+ diffCommand: string
16+
17+ /**
18+ * The program that lists untracked files: `git`.
19+ */
20+ lister: string
21+
22+ /**
23+ * The dim lines under an untracked row: what it is, and how to get its
24+ * line counts (a pasteable command only when the path is safe to paste).
25+ */
26+ untrackedNoteOf: (path: string, isPasteable: boolean) => readonly string[]
27+}
@@ -1,0 +1,3 @@
1+export type * from './backend-words.js'
2+
3+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './backend'
2+export * from './backend-host'
3+export * from './backend-probe'
4+export * from './backend-words'
5+
6+export * as default from '.'
@@ -1,0 +1,11 @@
1+import type Types from '../types'
2+
3+/**
4+ * What a child that never ran, was killed, or timed out reads as: exit code
5+ * -1 and no output, so a runner never rejects (GitRun).
6+ */
7+export const FAILED_RUN: Types.RunResult = Object.freeze({
8+ exitCode: -1,
9+ stdout: '',
10+ stderr: '',
11+})
@@ -1,0 +1,3 @@
1+export * from './failed-run.js'
2+
3+export * as default from '.'
@@ -1,0 +1,66 @@
1+import type Backend from '../backend'
2+import Limits from '../limits'
3+import Argv from './argv'
4+import { FAILED_RUN } from './failed-run'
5+import { fetchDiff } from './fetch-diff.js'
6+import { fetchFileHunks } from './fetch-file-hunks.js'
7+import { GIT_BASE_MODES } from './git-base-modes'
8+import { GIT_WORDS } from './git-words'
9+import Probes from './probes'
10+import type Types from './types'
11+
12+/**
13+ * 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).
17+ *
18+ * @param host the bound host's runner and probes
19+ * @returns the backend, or null outside a git working tree
20+ */
21+export async function gitBackendOf(
22+ host: Backend.BackendHost,
23+): Promise<Backend.Backend | null> {
24+ const runOf =
25+ (pinned: Types.Repository | null): Types.GitRun =>
26+ async argv => {
27+ try {
28+ return await host.run(
29+ ['git', ...(pinned ? Argv.pinnedLeadOf(pinned) : []), ...argv],
30+ {
31+ timeoutMs: Limits.GIT_TIMEOUT_MS,
32+ env: Argv.GIT_CHILD_ENV,
33+ ...(pinned && { cwd: pinned.toplevel }),
34+ },
35+ )
36+ } catch {
37+ return FAILED_RUN
38+ }
39+ }
40+
41+ const repository = await Probes.repositoryOf(runOf(null))
42+
43+ if (!repository) {
44+ return null
45+ }
46+
47+ const run = runOf(repository)
48+ const depsOf = (): Types.GitDeps => ({
49+ run,
50+ repository,
51+ mtimeOf: host.mtimeOf,
52+ entryKindsOf: host.entryKindsOf,
53+ sessionStartMs: host.sessionStartMsOf(),
54+ onBranchBase: host.onBranchBase,
55+ })
56+
57+ return {
58+ repository,
59+ baseModes: GIT_BASE_MODES,
60+ words: GIT_WORDS,
61+ fetchDiff: mode => fetchDiff(depsOf(), mode),
62+ fetchFileHunks: (data, file) => fetchFileHunks(run, data, file),
63+ headKeyOf: () =>
64+ Probes.headKeyOf({ ...depsOf(), readFile: host.readFile }, repository),
65+ }
66+}
@@ -1,0 +1,10 @@
1+import type Types from '../types'
2+
3+/**
4+ * The comparisons git offers, in picker order: all three.
5+ */
6+export const GIT_BASE_MODES: readonly Types.BaseMode[] = Object.freeze([
7+ 'session',
8+ 'uncommitted',
9+ 'branch',
10+])
@@ -1,0 +1,3 @@
1+export * from './git-base-modes.js'
2+
3+export * as default from '.'
@@ -1,0 +1,17 @@
1+import type Backend from '../../backend'
2+
3+/**
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).
6+ */
7+export const GIT_WORDS: Backend.BackendWords = Object.freeze({
8+ base: 'HEAD',
9+ diffCommand: 'git diff',
10+ lister: 'git',
11+ untrackedNoteOf: (path: string, isPasteable: boolean) => [
12+ 'New file not yet staged.',
13+ isPasteable
14+ ? `Run \`git add ':/${path}'\` to see line counts.`
15+ : 'Stage it with git add to see line counts.',
16+ ],
17+})
@@ -1,0 +1,3 @@
1+export * from './git-words.js'
2+
3+export * as default from '.'
@@ -1,8 +1,12 @@
11 export * from './argv'
22 export * from './cut-hunks'
33 export * from './empty-file-hunks'
4+export * from './failed-run'
45 export * from './fetch-diff.js'
56 export * from './fetch-file-hunks.js'
7+export * from './git-backend-of.js'
8+export * from './git-base-modes'
9+export * from './git-words'
610 export * from './hunk-of'
711 export * from './large-file-hunks'
812 export * from './parse'
@@ -3,15 +3,17 @@
33 import { MOST_UTF8_BYTES } from './most-utf8-bytes'
44
55 /**
6- * Whether a `-z` listing arrived whole: every record NUL-terminated and
7- * the text short of the host's output cap, so no record was cut or lost.
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.
89 *
910 * A cut inside a multi-byte character costs the decoded text up to
1011 * MOST_UTF8_BYTES, hence the margin under the cap.
1112 *
1213 * @param stdout the listing as `$.process.run` returned it
14+ * @param terminator what ends every record; NUL when absent
1315 * @returns whether it can be parsed as the whole answer
1416 */
15-export const isWholeListing = (stdout: string) =>
16- (stdout === '' || stdout.endsWith('\0')) &&
17+export const isWholeListing = (stdout: string, terminator = '\0') =>
18+ (stdout === '' || stdout.endsWith(terminator)) &&
1719 isUnderUtf8Ceiling(stdout, Limits.HOST_OUTPUT_CAP_BYTES - MOST_UTF8_BYTES)
@@ -22,6 +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'
2526 export * from './untracked-place'
2627
2728 export * as default from '.'
@@ -13,7 +13,7 @@
1313 * @returns the rows, tagged
1414 */
1515 export async function tagPreSession(
16- context: Pick<Types.FetchContext, 'deps' | 'stamps'>,
16+ context: Types.DatingContext,
1717 files: readonly Types.FileStat[],
1818 ): Promise<readonly Types.FileStat[]> {
1919 const datings = await datingsOf(
@@ -1,25 +1,20 @@
1-import Limits from '../../../limits'
21 import Argv from '../../argv'
32 import GitParse from '../../parse'
43 import type Types from '../../types'
5-import { datingsOf } from '../datings-of'
64 import type { UntrackedPlace } from '../untracked-place'
5+import { untrackedRowsOf } from '../untracked-rows-of'
76
87 /**
9- * The untracked, unignored files as 0/0 rows: the session's own first,
10- * then, when the scope asks, the tagged pre-session ones, up to the slots.
8+ * The untracked, unignored files as 0/0 rows (untrackedRowsOf), listed
9+ * with `ls-files -z`. A listing not given whole: no answer.
1110 *
12- * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the
13- * listing budget reads as pre-session, an undatable one as the session's
14- * (the built-in panel's rule). A listing not given whole: no answer.
15- *
1611 * @param context the fetch: its pinned git, stamp probe and session start
1712 * @param place the free row slots and the scope
1813 * @returns the rows to merge after the tracked ones, or null when the
1914 * `-z` listing failed, timed out or was cut
2015 */
2116 export async function untrackedFiles(
22- context: Pick<Types.FetchContext, 'run' | 'deps' | 'stamps'>,
17+ context: Pick<Types.FetchContext, 'run'> & Types.DatingContext,
2318 place: UntrackedPlace,
2419 ): Promise<readonly Types.FileStat[] | null> {
2520 if (place.slots <= 0) {
@@ -40,34 +35,6 @@
4035 }
4136
4237 const paths = listing.stdout.split('\0').filter(path => path !== '')
43- const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES)
44- const datings = await datingsOf(
45- context.stamps,
46- context.deps.sessionStartMs,
47- probedPaths,
48- )
49- const probed = probedPaths.map(path => ({
50- path,
51- isPreSession: datings.get(path) !== 'session',
52- }))
53- const isWithPreSession = place.scope === 'with-pre-session'
54- const earlier = isWithPreSession
55- ? [
56- ...probed.filter(file => file.isPreSession),
57- ...paths
58- .slice(Limits.MAX_UNTRACKED_PROBES)
59- .map(path => ({ path, isPreSession: true })),
60- ]
61- : []
62- const ordered = [...probed.filter(file => !file.isPreSession), ...earlier]
6338
64- return ordered.slice(0, place.slots).map(file => ({
65- path: file.path,
66- renamedFrom: null,
67- added: 0,
68- removed: 0,
69- isBinary: false,
70- isUntracked: true,
71- isPreSession: file.isPreSession,
72- }))
39+ return untrackedRowsOf(context, paths, place)
7340 }
@@ -1,0 +1,3 @@
1+export * from './untracked-rows-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,54 @@
1+import Limits from '../../../limits'
2+import type Types from '../../types'
3+import { datingsOf } from '../datings-of'
4+import type { UntrackedPlace } from '../untracked-place'
5+
6+/**
7+ * Listed untracked paths as 0/0 rows: the session's own first, then,
8+ * when the scope asks, the tagged pre-session ones, up to the slots.
9+ *
10+ * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the
11+ * listing budget reads as pre-session, an undatable one as the session's
12+ * (the built-in panel's rule).
13+ *
14+ * @param context the fetch's stamp probe and session start
15+ * @param paths the untracked paths, root-relative, as the lister printed them
16+ * @param place the free row slots and the scope
17+ * @returns the rows to merge after the tracked ones
18+ */
19+export async function untrackedRowsOf(
20+ context: Types.DatingContext,
21+ paths: readonly string[],
22+ place: UntrackedPlace,
23+): Promise<readonly Types.FileStat[]> {
24+ const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES)
25+ const datings = await datingsOf(
26+ context.stamps,
27+ context.deps.sessionStartMs,
28+ probedPaths,
29+ )
30+ const probed = probedPaths.map(path => ({
31+ path,
32+ isPreSession: datings.get(path) !== 'session',
33+ }))
34+ const isWithPreSession = place.scope === 'with-pre-session'
35+ const earlier = isWithPreSession
36+ ? [
37+ ...probed.filter(file => file.isPreSession),
38+ ...paths
39+ .slice(Limits.MAX_UNTRACKED_PROBES)
40+ .map(path => ({ path, isPreSession: true })),
41+ ]
42+ : []
43+ const ordered = [...probed.filter(file => !file.isPreSession), ...earlier]
44+
45+ return ordered.slice(0, place.slots).map(file => ({
46+ path: file.path,
47+ renamedFrom: null,
48+ added: 0,
49+ removed: 0,
50+ isBinary: false,
51+ isUntracked: true,
52+ isPreSession: file.isPreSession,
53+ }))
54+}
@@ -20,6 +20,6 @@
2020 case 'head-is-base':
2121 return { kind: 'branch', baseBranch: base.baseBranch, baseRef: 'HEAD' }
2222 case 'none':
23- return { kind: 'working-tree' }
23+ return { kind: 'working-tree', base: 'HEAD' }
2424 }
2525 }
@@ -48,7 +48,7 @@
4848 return {
4949 kind: 'data',
5050 data: diffDataOf(context, merged, {
51- source: { kind: 'working-tree' },
51+ source: { kind: 'working-tree', base: 'HEAD' },
5252 baseRef: '--cached',
5353 isUnborn: true,
5454 stalePaths: unstaged.files.map(file => file.path),
@@ -1,4 +1,5 @@
11 export * from './merged-result-of'
2+export * from './merged-with-untracked'
23 export * from './with-untracked.js'
34
45 export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './merged-with-untracked.js'
2+
3+export * as default from '.'
@@ -1,0 +1,32 @@
1+import type Types from '../../../types'
2+import { mergedResultOf } from '../merged-result-of'
3+
4+/**
5+ * 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.
8+ *
9+ * A path a tracked row already holds keeps that row and counts once.
10+ *
11+ * @param result the tier's tracked rows and totals
12+ * @param untracked the untracked rows, or null when their listing was withheld
13+ * @returns the merged rows and totals, and whether the listing was withheld
14+ */
15+export function mergedWithUntracked(
16+ result: Types.NumstatResult,
17+ untracked: readonly Types.FileStat[] | null,
18+): Types.MergedResult {
19+ if (!untracked) {
20+ return mergedResultOf(result, { isUntrackedWithheld: true })
21+ }
22+
23+ const tracked = new Set(result.files.map(file => file.path))
24+ const fresh = untracked.filter(file => !tracked.has(file.path))
25+ const filesCount = result.stats.filesCount + fresh.length
26+ const stats = { ...result.stats, filesCount }
27+
28+ return mergedResultOf(
29+ { stats, files: [...result.files, ...fresh] },
30+ { isUntrackedWithheld: false },
31+ )
32+}
@@ -3,14 +3,14 @@
33 import type Types from '../../types'
44 import { isPastDetails } from '../is-past-details'
55 import { mergedResultOf } from './merged-result-of'
6+import { mergedWithUntracked } from './merged-with-untracked'
67
78 /**
8- * A tier's rows with the untracked files merged after them, as the built-in
9- * panel merges the session's untracked files.
9+ * A tier's rows with the untracked files merged after them
10+ * (mergedWithUntracked).
1011 *
11- * A path a numstat row already holds keeps that row and counts once. Past
12- * MAX_FILES_FOR_DETAILS nothing is listed; a listing git withheld leaves
13- * the tracked rows as they are and says so, as the built-in degrades.
12+ * Past MAX_FILES_FOR_DETAILS nothing is listed; a listing git withheld
13+ * leaves the tracked rows as they are and says so, as the built-in degrades.
1414 *
1515 * @param context the fetch
1616 * @param result the tier's tracked rows and totals
@@ -26,22 +26,11 @@
2626 return mergedResultOf(result, { isUntrackedWithheld: false })
2727 }
2828
29- const tracked = new Set(result.files.map(file => file.path))
30- const untracked = await Probes.untrackedFiles(context, {
31- slots: Limits.MAX_FILES - result.files.length,
32- scope,
33- })
34-
35- if (!untracked) {
36- return mergedResultOf(result, { isUntrackedWithheld: true })
37- }
38-
39- const fresh = untracked.filter(file => !tracked.has(file.path))
40- const filesCount = result.stats.filesCount + fresh.length
41- const stats = { ...result.stats, filesCount }
42-
43- return mergedResultOf(
44- { stats, files: [...result.files, ...fresh] },
45- { isUntrackedWithheld: false },
29+ return mergedWithUntracked(
30+ result,
31+ await Probes.untrackedFiles(context, {
32+ slots: Limits.MAX_FILES - result.files.length,
33+ scope,
34+ }),
4635 )
4736 }
@@ -5,7 +5,7 @@
55 * every body current.
66 */
77 export const WORKING_TREE_BASIS: DiffDataOf.DiffBasis = Object.freeze({
8- source: Object.freeze({ kind: 'working-tree' }),
8+ source: Object.freeze({ kind: 'working-tree', base: 'HEAD' }),
99 baseRef: 'HEAD',
1010 isUnborn: false,
1111 stalePaths: Object.freeze([]),
@@ -1,0 +1,11 @@
1+import type { GitDeps } from '../git-deps'
2+import type { StampProbe } from '../stamp-probe'
3+
4+/**
5+ * What dating a path against the session takes: the session's start and
6+ * one stamp probe over the working tree's top.
7+ */
8+export type DatingContext = {
9+ deps: Pick<GitDeps, 'sessionStartMs'>
10+ stamps: StampProbe
11+}
@@ -1,0 +1,3 @@
1+export type * from './dating-context.js'
2+
3+export * as default from '.'
@@ -6,14 +6,15 @@
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.
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).
1011 *
1112 * `isUnborn`: a repository before its first commit (rows are the staged
1213 * and new files, `baseRef` is `--cached`); `stalePaths`: files whose staged
1314 * body is behind the working tree; `isUntrackedWithheld`: MergedResult's.
1415 */
1516 export type DiffData = {
16- repository: Repository
17+ repository: Pick<Repository, 'toplevel'>
1718 mode: BaseMode
1819 stats: DiffStats
1920 files: readonly FileStat[]
@@ -1,7 +1,9 @@
11 /**
2- * What a fetched diff compares: the working tree against HEAD, or against
3- * the merge-base with the default branch (`baseRef` the ref git was handed).
2+ * 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).
46 */
57 export type DiffSource =
6- | { kind: 'working-tree' }
8+ | { kind: 'working-tree'; base: string }
79 | { kind: 'branch'; baseBranch: string; baseRef: string }
@@ -1,6 +1,7 @@
11 export * from './base-mode'
22 export * from './branch-base'
33 export * from './dating'
4+export * from './dating-context'
45 export * from './diff-data'
56 export * from './diff-source'
67 export * from './diff-stats'
@@ -1,5 +1,6 @@
11 export * from './array-of'
22 export * from './ask'
3+export * from './backend'
34 export * from './classify'
45 export * from './command-spec'
56 export * from './count-of'
@@ -1,6 +1,9 @@
11 /**
2- * The pane's word for a fetch whose untracked listing git did not give
3- * whole: the tracked rows stand, the new files are not counted.
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
47 */
5-export const UNTRACKED_WITHHELD_TEXT =
6- 'Untracked files unavailable (git could not list them); not counted'
8+export const untrackedWithheldTextOf = (lister: string) =>
9+ `Untracked files unavailable (${lister} could not list them); not counted`
@@ -1,3 +1,4 @@
1+import type Backend from '../backend'
12 import type Git from '../git'
23 import { modeLabelOf } from './mode-label-of'
34
@@ -12,12 +13,14 @@
1213 * @param requested the mode the person picked
1314 * @param data the last good fetch
1415 * @param filesCount the header's session file count
16+ * @param words the backend's words
1517 * @returns the line, or null
1618 */
1719 export function baseLabelOf(
1820 requested: Git.BaseMode,
1921 data: Git.DiffData | null,
2022 filesCount: number,
23+ words: Pick<Backend.BackendWords, 'base'>,
2124 ): string | null {
2225 if (!data) {
2326 return null
@@ -37,7 +40,7 @@
3740 }
3841
3942 const phase = isPending ? 'pending' : 'settled'
40- const label = modeLabelOf(requested, data.source, phase)
43+ const label = modeLabelOf(requested, data.source, phase, words)
4144
4245 return isPending ? `${label}…` : label
4346 }
@@ -1,3 +1,4 @@
1+import type Backend from '../backend'
12 import type Git from '../git'
23 import Names from '../names'
34 import type { EmptyState } from './empty-state'
@@ -12,16 +13,18 @@
1213 *
1314 * @param data the last good fetch, or null when none ever settled
1415 * @param filesCount the header's session file count
16+ * @param words the backend's words
1517 * @returns the empty state, or null
1618 */
1719 export function emptyStateOf(
1820 data: Git.DiffData | null,
1921 filesCount: number,
22+ words: Backend.BackendWords,
2023 ): EmptyState | null {
2124 if (!data) {
2225 return {
2326 headline: 'Diff unavailable',
24- hint: "Couldn't read the git diff — it will retry on the next change",
27+ hint: `Couldn't read the ${words.diffCommand} — it will retry on the next change`,
2528 }
2629 }
2730
@@ -32,7 +35,7 @@
3235 if (data.isUntrackedWithheld) {
3336 return {
3437 headline: 'No tracked changes',
35- hint: Names.UNTRACKED_WITHHELD_TEXT,
38+ hint: Names.untrackedWithheldTextOf(words.lister),
3639 }
3740 }
3841
@@ -55,8 +58,10 @@
5558 }
5659
5760 return {
58- headline: 'No changes vs HEAD',
59- hint: 'No base branch to compare against — showing changes vs HEAD',
61+ headline: `No changes vs ${data.source.base}`,
62+ hint:
63+ 'No base branch to compare against — showing changes vs ' +
64+ data.source.base,
6065 }
6166 case 'session':
6267 return { headline: 'No changes this session', hint: null }
@@ -1,10 +1,13 @@
1+import Git from '../../git'
12 import type { PaneModel } from '../pane-model'
23
34 /**
45 * The pane before anything happened: session mode, nothing fetched,
5- * nothing picked, the current source.
6+ * nothing picked, the current source, git's words.
67 */
78 export const INITIAL_MODEL: PaneModel = Object.freeze({
9+ words: Git.GIT_WORDS,
10+ baseModes: Git.GIT_BASE_MODES,
811 isLoading: false,
912 hasSettled: false,
1013 isOutsideRepository: false,
@@ -1,29 +1,36 @@
1+import type Backend from '../../backend'
12 import type Git from '../../git'
23
34 /**
45 * A requested mode's name for the base line; branch mode names its base
5- * branch from the data, or says what stands in while none is known.
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.
69 *
710 * @param requested the mode the person picked
811 * @param source what the data on screen compares
912 * @param phase `pending` while the requested mode's fetch has not landed
13+ * @param words the backend's words
1014 * @returns the label without its pending ellipsis
1115 */
1216 export function modeLabelOf(
1317 requested: Git.BaseMode,
1418 source: Git.DiffSource,
1519 phase: 'pending' | 'settled',
20+ words: Pick<Backend.BackendWords, 'base'>,
1621 ) {
22+ const base = source.kind === 'working-tree' ? source.base : words.base
23+
1724 switch (requested) {
1825 case 'session':
1926 return 'this session'
2027 case 'uncommitted':
21- return 'uncommitted (vs HEAD)'
28+ return `uncommitted (vs ${base})`
2229 case 'branch':
2330 if (source.kind === 'branch') {
2431 return `branch vs ${source.baseBranch}`
2532 }
2633
27- return phase === 'pending' ? 'branch diff' : 'vs HEAD (no base branch)'
34+ return phase === 'pending' ? 'branch diff' : `vs ${base} (no base branch)`
2835 }
2936 }
@@ -1,3 +1,4 @@
1+import type Backend from '../../backend'
12 import type Git from '../../git'
23 import type Todos from '../../todos'
34 import type Turns from '../../turns'
@@ -6,9 +7,12 @@
67
78 /**
89 * Everything one drawing of the pane reads: the last good fetch, what the
9- * person picked, the selected file's body, the transcript's turns and todos.
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).
1012 */
1113 export type PaneModel = {
14+ words: Backend.BackendWords
15+ baseModes: readonly Git.BaseMode[]
1216 isLoading: boolean
1317 hasSettled: boolean
1418 isOutsideRepository: boolean
@@ -1,9 +1,10 @@
11 import type { On, ResultOf, SessionMessage, Timer } from 'claude-code'
22
33 import Ask from './ask'
4+import Backend from './backend'
45 import { COMMAND_SPEC } from './command-spec'
56 import { entryKindsOf } from './entry-kinds-of'
6-import Git from './git'
7+import type Git from './git'
78 import type { Host } from './host'
89 import { isOnPaneSurface } from './is-on-pane-surface'
910 import Limits from './limits'
@@ -22,13 +23,15 @@
2223 *
2324 * `session.start` binds the engine once, registers the command (refused
2425 * because the built-in holds it, the plugin does nothing) and pins the
25- * repository (probed again on `/diff` and first edit until found).
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).
2629 *
2730 * @param on the engine's registrar
2831 */
2932 export function register(on: On) {
3033 let host: Host | null = null
31- let repository: Git.Repository | null = null
34+ let backend: Backend.Backend | null = null
3235 let sessionStartMs = 0
3336 let isPaneOpen = false
3437 let hasAutoOpened = false
@@ -40,70 +43,68 @@
4043 let isRefreshQueued = false
4144 let generation = 0
4245 let bodyKey: string | null = null
43- const polled = { gitDir: '', headKey: '' }
46+ const polled = { toplevel: '', headKey: '' }
4447 let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
4548 const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>()
4649 const loggedBaseKinds = new Set<'ok' | 'sad'>()
4750
48- const gitOf =
49- (engine: Host, pinned: Git.Repository | null): Git.GitRun =>
50- async argv => {
51- try {
52- return await engine.run(
53- ['git', ...(pinned ? Git.pinnedLeadOf(pinned) : []), ...argv],
54- {
55- timeoutMs: Limits.GIT_TIMEOUT_MS,
56- env: Git.GIT_CHILD_ENV,
57- ...(pinned && { cwd: pinned.toplevel }),
58- },
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.
54+ const currentOf = (engine: Host): Host => host ?? engine
55+
56+ const backendHostOf = (engine: Host): Backend.BackendHost => ({
57+ run: (argv, init) => currentOf(engine).run(argv, init),
58+ readFile: path => currentOf(engine).readFile(path),
59+ mtimeOf: path => mtimeOf(currentOf(engine))(path),
60+ entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir),
61+ nowMs: () => currentOf(engine).now(),
62+ sessionStartMsOf: () => sessionStartMs,
63+ onBranchBase: base => {
64+ const isError = base.kind === 'error'
65+ const outcome: Record.MarkOutcome = isError
66+ ? { kind: 'sad', reason: base.reason }
67+ : { kind: 'ok' }
68+
69+ if (!loggedBaseKinds.has(outcome.kind)) {
70+ loggedBaseKinds.add(outcome.kind)
71+ Record.recorderOf(currentOf(engine)).mark(
72+ Record.FEATURES.baseResolve,
73+ outcome,
5974 )
60- } catch {
61- return { exitCode: -1, stdout: '', stderr: '' }
6275 }
63- }
76+ },
77+ })
6478
65- async function pinRepository(engine: Host): Promise<void> {
66- if (repository) {
79+ async function pinBackend(engine: Host): Promise<void> {
80+ if (backend) {
6781 return
6882 }
6983
70- const probed = await Git.repositoryOf(gitOf(engine, null))
71- repository ??= probed
84+ const probed = await Backend.backendOf(
85+ backendHostOf(engine),
86+ Backend.INSTALLED_BACKEND_PROBES,
87+ )
88+ backend ??= probed
7289
73- if (!probed || repository !== probed) {
90+ if (!probed || backend !== probed) {
7491 return
7592 }
7693
77- const mode = PaneState.baseModeOf(
94+ const stored = PaneState.baseModeOf(
7895 await engine
79- .storeGet(Names.baseStoreKeyOf(probed.toplevel))
96+ .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel))
8097 .catch(() => undefined),
8198 )
82-
83- if (mode) {
84- model = { ...model, requestedMode: mode }
99+ const mode = stored && probed.baseModes.includes(stored) ? stored : null
100+ model = {
101+ ...model,
102+ words: probed.words,
103+ baseModes: probed.baseModes,
104+ ...(mode && { requestedMode: mode }),
85105 }
86106 }
87107
88- const depsOf = (engine: Host, pinned: Git.Repository): Git.GitDeps => ({
89- run: gitOf(engine, pinned),
90- repository: pinned,
91- mtimeOf: mtimeOf(engine),
92- entryKindsOf: entryKindsOf(engine),
93- sessionStartMs,
94- onBranchBase: base => {
95- const isError = base.kind === 'error'
96- const outcome: Record.MarkOutcome = isError
97- ? { kind: 'sad', reason: base.reason }
98- : { kind: 'ok' }
99-
100- if (!loggedBaseKinds.has(outcome.kind)) {
101- loggedBaseKinds.add(outcome.kind)
102- Record.recorderOf(engine).mark(Record.FEATURES.baseResolve, outcome)
103- }
104- },
105- })
106-
107108 function redraw(engine: Host) {
108109 if (timers.has('redraw')) {
109110 return
@@ -134,7 +135,7 @@
134135 const { data } = model
135136 const selected = selectedOf()
136137
137- if (!data || !selected || !repository) {
138+ if (!data || !selected || !backend) {
138139 bodyKey = null
139140 model = { ...model, body: null, bodyState: 'idle' }
140141
@@ -150,11 +151,7 @@
150151 bodyKey = key
151152 model = { ...model, body: null, bodyState: 'loading' }
152153 redraw(engine)
153- const body = await Git.fetchFileHunks(
154- gitOf(engine, repository),
155- data,
156- selected,
157- )
154+ const body = await backend.fetchFileHunks(data, selected)
158155
159156 if (bodyKey !== key) {
160157 return
@@ -164,24 +161,15 @@
164161 redraw(engine)
165162 }
166163
167- function startPoll(engine: Host, polledRepository: Git.Repository) {
168- const readHeadKey = () =>
169- Git.headKeyOf(
170- {
171- run: gitOf(engine, polledRepository),
172- mtimeOf: mtimeOf(engine),
173- entryKindsOf: entryKindsOf(engine),
174- readFile: path => engine.readFile(path),
175- },
176- polledRepository,
177- ).catch(() => '')
164+ function startPoll(engine: Host, pinned: Backend.Backend) {
165+ const readHeadKey = () => pinned.headKeyOf().catch(() => '')
178166
179- if (polled.gitDir === polledRepository.gitDir) {
167+ if (polled.toplevel === pinned.repository.toplevel) {
180168 return
181169 }
182170
183171 timers.get('poll')?.cancel()
184- polled.gitDir = polledRepository.gitDir
172+ polled.toplevel = pinned.repository.toplevel
185173 polled.headKey = ''
186174 timers.set(
187175 'poll',
@@ -211,9 +199,10 @@
211199
212200 isRefreshing = true
213201 const record = Record.recorderOf(engine)
202+ const pinned = backend
214203 const fetched = (): Promise<Git.FetchOutcome> =>
215- repository
216- ? Git.fetchDiff(depsOf(engine, repository), model.requestedMode)
204+ pinned
205+ ? pinned.fetchDiff(model.requestedMode)
217206 : Promise.resolve({ kind: 'no-repository' })
218207
219208 try {
@@ -236,7 +225,11 @@
236225 case 'data':
237226 record.mark(Record.FEATURES.read, { kind: 'ok' })
238227 generation += 1
239- startPoll(engine, outcome.data.repository)
228+
229+ if (pinned) {
230+ startPoll(engine, pinned)
231+ }
232+
240233 break
241234 }
242235
@@ -304,7 +297,7 @@
304297 }
305298
306299 const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
307- await pinRepository(engine)
300+ await pinBackend(engine)
308301 const isKeptOpen = preference === true
309302 const floor = isKeptOpen
310303 ? Limits.OPEN_MIN_COLUMNS
@@ -313,7 +306,7 @@
313306 preference !== false &&
314307 columns !== null &&
315308 columns >= floor &&
316- repository !== null
309+ backend !== null
317310
318311 if (!isEligible || isTaken()) {
319312 return
@@ -422,7 +415,7 @@
422415 return
423416 }
424417
425- await pinRepository(engine)
418+ await pinBackend(engine)
426419 }
427420
428421 on('session.start', async ($, e, next) => {
@@ -467,7 +460,7 @@
467460 const { Box, Text, Button, Select } = await $.ui.resolve(e)
468461 wasDrawnSinceProbe = true
469462 columns = e.viewport?.columns ?? columns
470- model = { ...model, isFocused: e.props.focused }
463+ model = { ...model, isFocused: e.props.isFocused }
471464
472465 return Views.paneView(
473466 {
@@ -484,9 +477,9 @@
484477 return next(e)
485478 }
486479
487- await pinRepository(host)
480+ await pinBackend(host)
488481
489- if (!repository) {
482+ if (!backend) {
490483 return { text: Names.NOT_IN_REPOSITORY_TEXT }
491484 }
492485
@@ -20,7 +20,7 @@
2020 ): TodoProgress {
2121 const latest = messages
2222 .flatMap(message => message.toolUses)
23- .findLast(use => use.name === Tools.TODO_TOOL)
23+ .findLast(use => use.tool === Tools.TODO_TOOL)
2424 const statuses = arrayOf(latest?.input.todos).map(todo =>
2525 isRecord(todo) ? todo.status : null,
2626 )
@@ -46,11 +46,12 @@
4646 const totals = data
4747 ? PaneState.headerTotalsOf(data, partition)
4848 : PaneState.ZERO_TOTALS
49- const empty = PaneState.emptyStateOf(data, totals.filesCount)
49+ const empty = PaneState.emptyStateOf(data, totals.filesCount, model.words)
5050 const baseLabel = PaneState.baseLabelOf(
5151 model.requestedMode,
5252 data,
5353 totals.filesCount,
54+ model.words,
5455 )
5556 const selected = PaneState.selectionOf(
5657 PaneState.listedOf(partition, preSession),
@@ -89,6 +90,7 @@
8990 Detail.detailView(
9091 kit,
9192 {
93+ words: model.words,
9294 path: selected.path,
9395 renamedFrom: selected.renamedFrom,
9496 isUntracked: selected.isUntracked,
@@ -114,7 +116,11 @@
114116 typeof line === 'string' ? noteOf(line) : rowOf(line),
115117 ),
116118 noteOf(totals.notShown > 0 ? `${totals.notShown} not shown` : null),
117- noteOf(isUntrackedNoted ? Names.UNTRACKED_WITHHELD_TEXT : null),
119+ noteOf(
120+ isUntrackedNoted
121+ ? Names.untrackedWithheldTextOf(model.words.lister)
122+ : null,
123+ ),
118124 earlierToggle,
119125 ...earlierRows.map(rowOf),
120126 ...detail,
@@ -1,11 +1,14 @@
11 import type Types from '../types'
22
33 /**
4- * The theme key behind a run of each kind: the diff's added and removed
5- * bands, the plain background behind context.
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.
68 */
7-export const BACKGROUNDS: Readonly<Record<Types.LineKind, string>> = {
8- added: 'diffAdded',
9- removed: 'diffRemoved',
10- context: 'background',
11-}
9+export const BACKGROUNDS: Readonly<Record<Types.LineKind, string | undefined>> =
10+ {
11+ added: 'diffAdded',
12+ removed: 'diffRemoved',
13+ context: undefined,
14+ }
@@ -1,11 +1,14 @@
1+import type Backend from '../../../backend'
12 import type Git from '../../../git'
23 import type PaneState from '../../../pane-state'
34
45 /**
56 * The selected file as its body draws it: the path, what kind of row it is,
6- * its hunks once read, and whether it is armed for the next prompt.
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.
79 */
810 export type DetailModel = {
11+ words: Pick<Backend.BackendWords, 'untrackedNoteOf'>
912 path: string
1013 renamedFrom: string | null
1114 isUntracked: boolean
@@ -5,23 +5,19 @@
55 * The dim italic lines drawn instead of hunks (DiffDetailView's wording),
66 * or null when the hunks draw.
77 *
8- * Untracked (the pasteable `git add` line only for a name of letters,
9- * digits and `._/@+-`, any other told to stage without one), binary,
10- * loading, unreadable, large, or empty.
8+ * Untracked (the backend's note, its pasteable command only for a name of
9+ * letters, digits and `._/@+-`), binary, loading, unreadable, large, or
10+ * empty.
1111 *
1212 * @param detail the selected file
1313 * @returns the lines, or null
1414 */
1515 export function placeholderOf(detail: DetailModel): readonly string[] | null {
1616 if (detail.isUntracked) {
17- const isPasteable = SAFE_PATHSPEC_PATTERN.test(detail.path)
18-
19- return [
20- 'New file not yet staged.',
21- isPasteable
22- ? `Run \`git add ':/${detail.path}'\` to see line counts.`
23- : 'Stage it with git add to see line counts.',
24- ]
17+ return detail.words.untrackedNoteOf(
18+ detail.path,
19+ SAFE_PATHSPEC_PATTERN.test(detail.path),
20+ )
2521 }
2622
2723 if (detail.isBinary) {
@@ -1,5 +1,5 @@
11 /**
2- * A file name safe to paste inside `git add ':/…'` in any shell (POSIX,
2+ * A file name safe to paste into a stage command in any shell (POSIX,
33 * PowerShell, cmd): letters and digits of any script and `._/@+-` only.
44 *
55 * No quote of any kind, no separator and no substitution can ride in it.
@@ -1,11 +1,13 @@
11 import type Types from '../types'
22
33 /**
4- * The theme key behind a changed word of each kind (a context line never
5- * carries one).
4+ * The theme key behind a changed word of each kind; a context line never
5+ * carries one, so it has none.
66 */
7-export const WORD_BACKGROUNDS: Readonly<Record<Types.LineKind, string>> = {
7+export const WORD_BACKGROUNDS: Readonly<
8+ Record<Types.LineKind, string | undefined>
9+> = {
810 added: 'diffAddedWord',
911 removed: 'diffRemovedWord',
10- context: 'background',
12+ context: undefined,
1113 }
@@ -12,7 +12,9 @@
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.
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).
1618 *
1719 * @param kit the elements and the handlers
1820 * @param model what is picked now and the turns to pick from
@@ -20,7 +22,10 @@
2022 */
2123 export function controlsView(
2224 kit: Kit,
23- model: Pick<PaneState.PaneModel, 'source' | 'requestedMode' | 'turns'>,
25+ model: Pick<
26+ PaneState.PaneModel,
27+ 'source' | 'requestedMode' | 'turns' | 'baseModes' | 'words' | 'data'
28+ >,
2429 ): RenderElement | null {
2530 const { Box, Select } = kit.ui
2631 const { source, turns } = model
@@ -41,15 +46,24 @@
4146 />
4247 ) : null
4348 const isCurrent = source.kind === 'current'
49+ const fetchedSource = model.data?.source
50+ const base =
51+ fetchedSource?.kind === 'working-tree'
52+ ? fetchedSource.base
53+ : model.words.base
4454 const basePicker = isCurrent ? (
4555 <Select
4656 key="base"
4757 label="base"
48- options={[
49- { value: 'session', label: 'this session' },
50- { value: 'uncommitted', label: 'uncommitted (vs HEAD)' },
51- { value: 'branch', label: 'branch' },
52- ]}
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+ }))}
5367 value={model.requestedMode}
5468 onSelect={value => kit.actions.chooseBase(value)}
5569 />
@@ -49,6 +49,7 @@
4949 Detail.detailView(
5050 kit,
5151 {
52+ words: model.words,
5253 path: selected.path,
5354 renamedFrom: null,
5455 isUntracked: false,
@@ -7,16 +7,18 @@
77 `$.telemetry.mark({ feature, kind, reason? })` marks one use of a feature as
88 the CLI's own feature events do, `tengu_feature_<kind>` with a
99 `feature_name`. Each call is one POST to the event-logging ingest with the
10-session's own credential (`$.session.authorize()`, resolved once and held),
10+session's own credential (`$.session.authorize()`, resolved at each call),
1111 one attempt, nothing batched; a session with no first-party credential, or
1212 an ingest that refuses, rejects the caller's promise.
1313
1414 It sends nothing wherever the CLI's own analytics are off: under
1515 `DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
16-`DO_NOT_TRACK`, on a third-party provider (Bedrock, Vertex, Foundry and
17-kin, unless the host manages the provider), on a deployment with its own
18-OAuth URL, and under test; each read through `$.env` once a session. The
19-row's `user_type` is `ant` when `USER_TYPE` says so, else `external`.
16+`DO_NOT_TRACK`, on any third-party provider (Bedrock, Vertex, Foundry and
17+kin), and on a deployment with its own OAuth URL. Each is read through
18+`$.env` at every call, rows go one after another, and the credential is
19+authorized afresh right before each POST, so a session that has since moved
20+to a third-party provider or a cloud gateway sends nothing more. The row's
21+`user_type` is `ant` when `USER_TYPE` says so, else `external`.
2022
2123 Nothing free-form reaches a row. An event name and every property key is a
2224 snake_case token; a value is a finite number, a boolean, or a Choice (a
@@ -1,5 +1,5 @@
11 /**
2- * What the plugin reads of the environment, once a session: the
2+ * What the plugin reads of the environment before each row: the
33 * build's user type and every switch that turns the CLI's analytics off.
44 *
55 * Each field is the variable's value as `$.env.get` answers it, undefined
@@ -7,12 +7,10 @@
77 */
88 export type Environment = {
99 readonly userType: string | undefined
10- readonly nodeEnv: string | undefined
1110 readonly disableTelemetry: string | undefined
1211 readonly disableNonessentialTraffic: string | undefined
1312 readonly doNotTrack: string | undefined
1413 readonly customOauthUrl: string | undefined
15- readonly providerManagedByHost: string | undefined
1614 readonly useBedrock: string | undefined
1715 readonly useVertex: string | undefined
1816 readonly useFoundry: string | undefined
@@ -6,30 +6,27 @@
66 * Whether the CLI's own analytics would be off here, read off the
77 * environment alone: then the plugin sends nothing either.
88 *
9- * Off under test, when DISABLE_TELEMETRY or the nonessential-traffic switch
10- * is set at all or DO_NOT_TRACK is on, on a third-party provider the host
11- * does not manage, and on a deployment with its own OAuth URL.
9+ * Off when DISABLE_TELEMETRY or the nonessential-traffic switch is set at
10+ * all or DO_NOT_TRACK is on, on any third-party provider (no first-party
11+ * credential rides there), and on a deployment with its own OAuth URL.
1212 *
1313 * @param environment the variables as read once for the session
1414 * @returns true when no row may be sent
1515 */
1616 export function isAnalyticsOff(environment: Environment) {
17- const isTest = environment.nodeEnv === 'test'
1817 const isPrivate =
1918 isEnvSet(environment.disableTelemetry) ||
2019 isEnvSet(environment.disableNonessentialTraffic) ||
2120 isEnvTruthy(environment.doNotTrack)
22- const isThirdParty =
23- !isEnvTruthy(environment.providerManagedByHost) &&
24- [
25- environment.useBedrock,
26- environment.useVertex,
27- environment.useFoundry,
28- environment.useAnthropicAws,
29- environment.useAnthropicGoogleCloud,
30- environment.useMantle,
31- ].some(isEnvTruthy)
21+ const isThirdParty = [
22+ environment.useBedrock,
23+ environment.useVertex,
24+ environment.useFoundry,
25+ environment.useAnthropicAws,
26+ environment.useAnthropicGoogleCloud,
27+ environment.useMantle,
28+ ].some(isEnvTruthy)
3229 const isCustomDeployment = isEnvSet(environment.customOauthUrl?.trim())
3330
34- return isTest || isPrivate || isThirdParty || isCustomDeployment
31+ return isPrivate || isThirdParty || isCustomDeployment
3532 }
@@ -23,16 +23,12 @@
2323 model: () => beneath.session.model(),
2424 environment: async () => ({
2525 userType: await beneath.env.get('USER_TYPE'),
26- nodeEnv: await beneath.env.get('NODE_ENV'),
2726 disableTelemetry: await beneath.env.get('DISABLE_TELEMETRY'),
2827 disableNonessentialTraffic: await beneath.env.get(
2928 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
3029 ),
3130 doNotTrack: await beneath.env.get('DO_NOT_TRACK'),
3231 customOauthUrl: await beneath.env.get('CLAUDE_CODE_CUSTOM_OAUTH_URL'),
33- providerManagedByHost: await beneath.env.get(
34- 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
35- ),
3632 useBedrock: await beneath.env.get('CLAUDE_CODE_USE_BEDROCK'),
3733 useVertex: await beneath.env.get('CLAUDE_CODE_USE_VERTEX'),
3834 useFoundry: await beneath.env.get('CLAUDE_CODE_USE_FOUNDRY'),
@@ -23,8 +23,8 @@
2323 model: () => Promise<string>
2424
2525 /**
26- * Reads what the row and the off switch need of the environment:
27- * the build's user type, and every variable that turns analytics off.
26+ * Reads, at each call, what the row and the off switch need of the
27+ * environment: the build's user type and every analytics-off variable.
2828 */
2929 environment: () => Promise<Environment>
3030
@@ -4,60 +4,43 @@
44 import type TelemetryTypes from '../telemetry-types'
55
66 /**
7- * Builds `$.telemetry`: `log` and `mark` check the entry, authorize once, build
8- * the first-party row and POST it to the ingest.
7+ * Builds `$.telemetry`: `log` and `mark` check the entry, read the environment
8+ * and authorize afresh, build the first-party row and POST it to the ingest.
99 *
10- * One POST per call, none batched, one attempt; a session with no credential to
11- * authorize, or an ingest that refuses, rejects the caller's promise. A failed
12- * authorize or environment read is not memoized, so a later call retries it.
10+ * One POST per call, rows one after another, nothing kept between them: a
11+ * session that has moved to a third-party provider or a gateway, or turned
12+ * analytics off, sends nothing more; no credential or a refusal rejects.
1313 *
1414 * @param deps the calls on the nouns beneath
15- * @returns the `$.telemetry` interface, `log` and `mark`, each authorizing once
16- * (memoized) before it posts
15+ * @returns the `$.telemetry` interface, `log` and `mark`
1716 */
1817 export function telemetryOf(deps: TelemetryDeps): TelemetryTypes.Telemetry {
19- let held: ReturnType<TelemetryDeps['authorize']> | undefined
20- let read: ReturnType<TelemetryDeps['environment']> | undefined
18+ let queue: Promise<unknown> = Promise.resolve()
2119
2220 async function post(
2321 fields: Entries.Fields,
2422 method: TelemetryTypes.Method,
2523 ): Promise<void> {
26- read ??= deps.environment().catch((error: unknown) => {
27- read = undefined
24+ const environment = await deps.environment()
2825
29- throw error
30- })
31- const environment = await read
32-
3326 if (isAnalyticsOff(environment)) {
3427 return
3528 }
3629
37- if (!held) {
38- held = deps.authorize().catch((error: unknown) => {
39- held = undefined
30+ const body = Entries.batchOf(fields, {
31+ sessionId: await deps.id(),
32+ model: await deps.model(),
33+ userType: environment.userType === 'ant' ? 'ant' : 'external',
34+ })
35+ const auth = await deps.authorize()
4036
41- throw error
42- })
43- }
44-
45- const auth = await held
46-
4737 if (!auth) {
48- held = undefined
49-
5038 throw Entries.refusal(
5139 'this session has no first-party credential to authorize',
5240 method,
5341 )
5442 }
5543
56- const body = Entries.batchOf(fields, {
57- sessionId: await deps.id(),
58- model: await deps.model(),
59- userType: environment.userType === 'ant' ? 'ant' : 'external',
60- })
6144 const response = await deps.fetch(Entries.INGEST_URL, {
6245 method: 'POST',
6346 headers: {
@@ -73,8 +56,18 @@
7356 }
7457 }
7558
59+ function queued(
60+ fields: Entries.Fields,
61+ method: TelemetryTypes.Method,
62+ ): Promise<void> {
63+ const turn = queue.then(() => post(fields, method))
64+ queue = turn.catch(() => undefined)
65+
66+ return turn
67+ }
68+
7669 return {
77- log: async entry => post(Entries.checkedFields(entry), 'log'),
78- mark: async entry => post(Entries.checkedMark(entry), 'mark'),
70+ log: async entry => queued(Entries.checkedFields(entry), 'log'),
71+ mark: async entry => queued(Entries.checkedMark(entry), 'mark'),
7972 }
8073 }