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

mods: merge poteat/diff-parity; a mark's props, hyphened Choice members and the built-in panel's marks at its call sites, in the tested and linted layoutBrowse files
poteat authored on Sep 11, 2026 parents 59bb885 + bb429a9 · commit ead880635311
23 changed files+212 -84compared with 59bb885
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
Amods/diff/hooks/record/tabs/index.ts
Amods/diff/hooks/record/tabs/tabs.ts
Mmods/diff/hooks/register.ts
Mmods/diff/hooks/telemetry-noun.d.ts
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
Mmods/telemetry/hooks/entries/choice-token/choice-token.ts
Mmods/telemetry/hooks/entries/index.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/telemetry-types/choice/choice.ts
Mmods/telemetry/hooks/telemetry-types/mark-entry/mark-entry.ts
Mmods/telemetry/hooks/telemetry-types/telemetry.ts
@@ -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,8 +2,10 @@
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, `selectionAttach` per diff asked.
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',
@@ -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+}
@@ -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
@@ -61,7 +61,12 @@
6161 const isError = base.kind === 'error'
6262 const outcome: Record.MarkOutcome = isError
6363 ? { kind: 'sad', reason: base.reason }
64- : { kind: 'ok' }
64+ : {
65+ kind: 'ok',
66+ props: {
67+ outcome: { value: base.kind, of: Record.BASE_OUTCOMES },
68+ },
69+ }
6570
6671 if (!loggedBaseKinds.has(outcome.kind)) {
6772 loggedBaseKinds.add(outcome.kind)
@@ -148,7 +153,7 @@
148153 model.selectedPath,
149154 )
150155
151- async function loadBody(engine: Host): Promise<void> {
156+ async function loadBody(engine: Host): Promise<boolean> {
152157 const { data } = model
153158 const selected = selectedOf()
154159
@@ -156,13 +161,13 @@
156161 bodyKey = null
157162 model = { ...model, body: null, bodyState: 'idle' }
158163
159- return
164+ return false
160165 }
161166
162167 const key = `${generation}|${data.baseRef}|${selected.path}`
163168
164169 if (key === bodyKey) {
165- return
170+ return false
166171 }
167172
168173 bodyKey = key
@@ -171,11 +176,13 @@
171176 const body = await backend.fetchFileHunks(data, selected)
172177
173178 if (bodyKey !== key) {
174- return
179+ return body === null
175180 }
176181
177182 model = { ...model, body, bodyState: body ? 'ready' : 'failed' }
178183 redraw(engine)
184+
185+ return body === null
179186 }
180187
181188 function startPoll(engine: Host, pinned: Backend.Backend) {
@@ -240,7 +247,6 @@
240247 })
241248 break
242249 case 'data':
243- record.mark(Record.FEATURES.read, { kind: 'ok' })
244250 generation += 1
245251
246252 if (pinned) {
@@ -250,7 +256,23 @@
250256 break
251257 }
252258
253- await loadBody(engine)
259+ const hasHunksFailed = await loadBody(engine)
260+
261+ if (outcome.kind === 'data') {
262+ record.mark(
263+ Record.FEATURES.read,
264+ hasHunksFailed
265+ ? { kind: 'sad', reason: 'git_hunks_failed' }
266+ : { kind: 'ok' },
267+ )
268+ }
269+ } catch (error) {
270+ record.mark(Record.FEATURES.read, {
271+ kind: 'sad',
272+ reason: 'git_diff_threw',
273+ })
274+
275+ throw error
254276 } finally {
255277 isRefreshing = false
256278 redraw(engine)
@@ -281,13 +303,11 @@
281303 const isManual = trigger === 'manual'
282304 await engine.openPane(isManual ? { ...pane, ...Names.FOCUSED_PANE } : pane)
283305 isPaneOpen = true
284- const record = Record.recorderOf(engine)
285- record.mark(Record.FEATURES.tabSwitch, { kind: 'ok' })
286306 const sessionId = await engine.sessionId().catch(() => null)
287307
288308 if (sessionId !== null && sessionId !== shownSessionId) {
289309 shownSessionId = sessionId
290- record.shown(trigger, Record.widthBucketOf(columns))
310+ Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns))
291311 }
292312
293313 void refresh(engine)
@@ -296,9 +316,15 @@
296316 async function closePane(engine: Host): Promise<void> {
297317 await engine.closePane({ id: Names.PANE_ID })
298318 isPaneOpen = false
299- Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { kind: 'ok' })
300319 }
301320
321+ function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) {
322+ Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, {
323+ kind: 'ok',
324+ props: { tab: { value: tab, of: Record.TABS } },
325+ })
326+ }
327+
302328 async function wasDrawnWhenProbed(engine: Host): Promise<boolean> {
303329 wasDrawnSinceProbe = false
304330 engine.invalidate()
@@ -367,6 +393,7 @@
367393 model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' }
368394 Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, {
369395 kind: 'ok',
396+ props: { mode: { value: mode, of: model.baseModes } },
370397 })
371398 const toplevel = model.data?.repository.toplevel
372399
@@ -401,6 +428,7 @@
401428 },
402429 close: () => {
403430 void closePane(engine)
431+ .then(() => markTabSwitch(engine, 'convo'))
404432 .then(() => engine.storeSet(Names.STORE_OPEN_KEY, false))
405433 .catch(() => undefined)
406434 },
@@ -525,6 +553,7 @@
525553
526554 const isOpening = toggle === 'open'
527555 await (isOpening ? openPane(host, 'manual') : closePane(host))
556+ markTabSwitch(host, isOpening ? 'diff' : 'convo')
528557 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
529558
530559 return {}
@@ -34,13 +34,14 @@
3434 }
3535
3636 /**
37- * What `$.telemetry.mark` takes: the feature, how it went, and why when
38- * not ok.
37+ * What `$.telemetry.mark` takes: the feature, how it went, why when not
38+ * ok, and the properties the row carries beside them by snake_case key.
3939 */
4040 type DiffTelemetryMarkEntry = {
4141 feature: string
4242 kind: DiffTelemetryMarkKind
4343 reason?: string
44+ props?: Readonly<Record<string, DiffTelemetryProp>>
4445 }
4546
4647 interface EngineInterface {
@@ -1,15 +1,16 @@
11 # telemetry
22
3-Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry`
4-to the engine interface every plugin above it is handed, built over the
3+Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry` to
4+the engine interface every plugin above it is handed, built over the
55 `$.session` and `$.http` nouns beneath. `$.telemetry.log({ event, props })`
66 sends one event as one first-party row, `tengu_plugin_<event>`;
7-`$.telemetry.mark({ feature, kind, reason? })` marks one use of a feature as
8-the CLI's own feature events do, `tengu_feature_<kind>` with a
9-`feature_name`. Each call is one POST to the event-logging ingest with the
10-session's own credential (`$.session.authorize()`, resolved at each call),
11-one attempt, nothing batched; a session with no first-party credential, or
12-an ingest that refuses, rejects the caller's promise.
7+`$.telemetry.mark({ feature, kind, reason?, props? })` marks one use of a
8+feature as the CLI's own feature events do, `tengu_feature_<kind>` with a
9+`feature_name` and the mark's properties beside it. Each call is one POST to
10+the event-logging ingest with the session's own credential
11+(`$.session.authorize()`, resolved at each call), one attempt, nothing
12+batched; a session with no first-party credential, or an ingest that
13+refuses, rejects the caller's promise.
1314
1415 It sends nothing wherever the CLI's own analytics are off: under
1516 `DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
@@ -22,9 +23,10 @@
2223
2324 Nothing free-form reaches a row. An event name and every property key is a
2425 snake_case token; a value is a finite number, a boolean, or a Choice (a
25-string named together with the list it is chosen from); `mark` takes `ok`,
26-`sad` or `bad`, with a `reason` required on the last two and refused on the
27-first. An entry that breaks a rule is refused before anything is sent.
26+string named together with the list it is chosen from), under `log` and
27+`mark` alike; `mark` takes `ok`, `sad` or `bad`, with a `reason` required on
28+the last two and refused on the first. An entry that breaks a rule is
29+refused before anything is sent.
2830
2931 `hooks/register.ts` is the module; `hooks/telemetry-types/` is the noun's
3032 type as a caller sees it.
@@ -1,8 +1,7 @@
1-import { checkedValue } from './checked-value'
1+import { checkedProps } from './checked-props'
22 import type { Fields } from './fields'
33 import { fieldsOf } from './fields-of'
44 import { isRecord } from './is-record'
5-import { PROP_LIMIT } from './prop-limit'
65 import { refusal } from './refusal'
76 import { TOKEN } from './token'
87
@@ -27,25 +26,5 @@
2726 throw refusal('takes an event name, a snake_case token')
2827 }
2928
30- if (!isRecord(props)) {
31- throw refusal('props: an object of properties by key')
32- }
33-
34- const entries = Object.entries(props)
35-
36- if (entries.length > PROP_LIMIT) {
37- throw refusal(`props: at most ${PROP_LIMIT} properties`)
38- }
39-
40- const checked: Record<string, string | number | boolean> = {}
41-
42- for (const [key, value] of entries) {
43- if (!TOKEN.test(key)) {
44- throw refusal('props: every key is a snake_case token')
45- }
46-
47- checked[key] = checkedValue(key, value)
48- }
49-
50- return fieldsOf(event, checked)
29+ return fieldsOf(event, checkedProps(props, 'log'))
5130 }
@@ -1,3 +1,4 @@
1+import { checkedProps } from './checked-props'
12 import type { Fields } from './fields'
23 import { isMarkKind } from './is-mark-kind'
34 import { isRecord } from './is-record'
@@ -7,17 +8,20 @@
78
89 /**
910 * The row's fields from one mark, or a refusal naming the first thing
10- * wrong: the entry's shape, the feature, the kind, then the reason.
11+ * wrong: the entry's shape, the feature, the kind, the reason, then props.
1112 *
12- * @param entry the feature, kind and reason as the caller passed them
13+ * Each property is checked in turn, as a log's are.
14+ *
15+ * @param entry the feature, kind, reason and properties as the caller passed
16+ * them
1317 * @returns the mark's row fields once the entry passes every check
1418 */
1519 export function checkedMark(entry: unknown): Fields {
1620 if (!isRecord(entry)) {
17- throw refusal('takes one entry, { feature, kind, reason? }', 'mark')
21+ throw refusal('takes one entry, { feature, kind, reason?, props? }', 'mark')
1822 }
1923
20- const { feature, kind, reason } = entry
24+ const { feature, kind, reason, props = {} } = entry
2125
2226 if (typeof feature !== 'string' || !TOKEN.test(feature)) {
2327 throw refusal('takes a feature name, a snake_case token', 'mark')
@@ -32,7 +36,7 @@
3236 throw refusal('reason: an ok mark carries none', 'mark')
3337 }
3438
35- return markFieldsOf(kind, feature)
39+ return markFieldsOf({ kind, feature, props: checkedProps(props, 'mark') })
3640 }
3741
3842 if (typeof reason !== 'string' || !TOKEN.test(reason)) {
@@ -42,5 +46,10 @@
4246 )
4347 }
4448
45- return markFieldsOf(kind, feature, reason)
49+ return markFieldsOf({
50+ kind,
51+ feature,
52+ reason,
53+ props: checkedProps(props, 'mark'),
54+ })
4655 }
@@ -1,0 +1,41 @@
1+import type TelemetryTypes from '../../telemetry-types'
2+import { checkedValue } from '../checked-value'
3+import { isRecord } from '../is-record'
4+import { PROP_LIMIT } from '../prop-limit'
5+import { refusal } from '../refusal'
6+import { TOKEN } from '../token'
7+
8+/**
9+ * An entry's properties as they go into the row, or a refusal naming the
10+ * first thing wrong: the shape, the count, then each key and value in turn.
11+ *
12+ * @param props what the caller passed as `props`
13+ * @param method the method the properties were passed to, named in a refusal
14+ * @returns the properties by key, each value checked
15+ */
16+export function checkedProps(
17+ props: unknown,
18+ method: TelemetryTypes.Method,
19+): Record<string, string | number | boolean> {
20+ if (!isRecord(props)) {
21+ throw refusal('props: an object of properties by key', method)
22+ }
23+
24+ const entries = Object.entries(props)
25+
26+ if (entries.length > PROP_LIMIT) {
27+ throw refusal(`props: at most ${PROP_LIMIT} properties`, method)
28+ }
29+
30+ const checked: Record<string, string | number | boolean> = {}
31+
32+ for (const [key, value] of entries) {
33+ if (!TOKEN.test(key)) {
34+ throw refusal('props: every key is a snake_case token', method)
35+ }
36+
37+ checked[key] = checkedValue(key, value, method)
38+ }
39+
40+ return checked
41+}
@@ -1,0 +1,3 @@
1+export * from './checked-props.js'
2+
3+export * as default from '.'
@@ -1,3 +1,4 @@
1+import type TelemetryTypes from '../../telemetry-types'
12 import { CHOICE_TOKEN } from '../choice-token'
23 import { CHOICES_LIMIT } from '../choices-limit'
34 import { isRecord } from '../is-record'
@@ -11,12 +12,14 @@
1112 *
1213 * @param key the property's key, named in a refusal
1314 * @param value what the caller passed under it
15+ * @param method the method the property was passed to, named in a refusal
1416 * @returns the value as stored: the boolean or finite number unchanged, or the
1517 * chosen member when value is a Choice
1618 */
1719 export function checkedValue(
1820 key: string,
1921 value: unknown,
22+ method: TelemetryTypes.Method = 'log',
2023 ): string | number | boolean {
2124 if (typeof value === 'boolean') {
2225 return value
@@ -27,13 +30,14 @@
2730 return value
2831 }
2932
30- throw refusal(`props.${key}: a number is finite`)
33+ throw refusal(`props.${key}: a number is finite`, method)
3134 }
3235
3336 if (typeof value === 'string') {
3437 throw refusal(
3538 `props.${key}: free text is refused; a string is a Choice, ` +
3639 `{ value, of: [...] }`,
40+ method,
3741 )
3842 }
3943
@@ -41,6 +45,7 @@
4145 throw refusal(
4246 `props.${key}: a value is a finite number, a boolean, or a Choice, ` +
4347 `{ value, of: [...] }`,
48+ method,
4449 )
4550 }
4651
@@ -55,12 +60,13 @@
5560
5661 if (!isTokenList) {
5762 throw refusal(
58- `props.${key}.of: a list of 1 to ${CHOICES_LIMIT} ` + `snake_case tokens`,
63+ `props.${key}.of: a list of 1 to ${CHOICES_LIMIT} ` + `lowercase tokens`,
64+ method,
5965 )
6066 }
6167
6268 if (typeof chosen !== 'string' || !members.includes(chosen)) {
63- throw refusal(`props.${key}.value: one of the members of \`of\``)
69+ throw refusal(`props.${key}.value: one of the members of \`of\``, method)
6470 }
6571
6672 return chosen
@@ -1,8 +1,8 @@
11 /**
2- * The shape of a Choice member: a snake_case token of at most 64
3- * characters.
2+ * The shape of a Choice member: a lowercase token of at most 64 characters,
3+ * of letters, digits, `_` and `-`.
44 *
55 * Unlike a name or a key (TOKEN), it may start with a digit (a bucket such
6- * as `110_to_143`).
6+ * as `110_to_143`) and carry a hyphen (a kind such as `merge-base`).
77 */
8-export const CHOICE_TOKEN = /^[a-z0-9][a-z0-9_]{0,63}$/
8+export const CHOICE_TOKEN = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -1,6 +1,7 @@
11 export * from './batch-of.js'
22 export * from './checked-fields.js'
33 export * from './checked-mark.js'
4+export * from './checked-props'
45 export * from './checked-value'
56 export * from './choice-token'
67 export * from './choices-limit'
@@ -12,6 +13,7 @@
1213 export * from './ingest-url.js'
1314 export * from './is-mark-kind'
1415 export * from './is-record'
16+export * from './mark'
1517 export * from './mark-fields-of'
1618 export * from './mark-kinds'
1719 export * from './prop-limit'
@@ -1,0 +1,3 @@
1+export type * from './mark.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+import type TelemetryTypes from '../../telemetry-types'
2+import type { Fields } from '../fields'
3+
4+/**
5+ * One mark past every check: the feature, how it went, why when not ok,
6+ * and its properties as they go into the row.
7+ */
8+export type Mark = {
9+ kind: TelemetryTypes.MarkKind
10+ feature: string
11+ reason?: string
12+ props: Fields['props']
13+}
@@ -1,24 +1,28 @@
1-import type TelemetryTypes from '../../telemetry-types'
21 import { FEATURE_PREFIX } from '../feature-prefix'
32 import type { Fields } from '../fields'
3+import type { Mark } from '../mark'
44
55 /**
66 * One checked mark as its fields: the CLI's own feature event,
77 * `tengu_feature_<kind>` with `feature_name`, and `error_code` when given.
88 *
9- * @param kind how the feature went
10- * @param feature the feature marked
11- * @param reason why, on a sad or bad mark; absent on ok
9+ * The mark's properties merge in as the CLI's feature events merge their
10+ * extras: after `feature_name` on an ok row, and beneath `feature_name`
11+ * and `error_code` on a sad or bad one.
12+ *
13+ * @param mark the feature, how it went, why when not ok, and its checked
14+ * properties
1215 * @returns the event's name and props, ready to log
1316 */
14-export const markFieldsOf = (
15- kind: TelemetryTypes.MarkKind,
16- feature: string,
17- reason?: string,
18-): Fields => ({
17+export const markFieldsOf = ({
18+ kind,
19+ feature,
20+ reason,
21+ props,
22+}: Mark): Fields => ({
1923 name: FEATURE_PREFIX + kind,
2024 props:
2125 reason === undefined
22- ? { feature_name: feature }
23- : { feature_name: feature, error_code: reason },
26+ ? { feature_name: feature, ...props }
27+ : { ...props, feature_name: feature, error_code: reason },
2428 })
@@ -2,7 +2,8 @@
22 * A string property: the value and the list it is chosen from, declared
33 * beside it, so no free text reaches the row.
44 *
5- * Every member of `of` is a snake_case token, which may start with a digit,
6- * at most CHOICES_LIMIT of them; `value` is one of them.
5+ * Every member of `of` is a lowercase token of letters, digits, `_` and
6+ * `-`, which may start with a digit, at most CHOICES_LIMIT of them; `value`
7+ * is one of them.
78 */
89 export type Choice = { value: string; of: readonly string[] }
@@ -1,11 +1,13 @@
11 import type { MarkKind } from '../mark-kind'
2+import type { Prop } from '../prop'
23
34 /**
4- * What `$.telemetry.mark` takes: the feature, how it went, and why when not
5- * ok.
5+ * What `$.telemetry.mark` takes: the feature, how it went, why when not
6+ * ok, and the properties the row carries beside them by snake_case key.
67 */
78 export type MarkEntry = {
89 feature: string
910 kind: MarkKind
1011 reason?: string
12+ props?: Readonly<Record<string, Prop>>
1113 }
@@ -34,15 +34,21 @@
3434 * Marks one use of a feature as the CLI's own feature events do, one
3535 * `tengu_feature_<kind>` row; resolves once the ingest accepted it.
3636 *
37- * The row carries `feature_name`, and `error_code` on a sad or bad one; it
38- * joins the product-wide feature surface (queried by `feature_name`), so
39- * no plugin prefix. `reason` is required on sad and bad, refused on ok.
37+ * The row carries `feature_name`, `error_code` on sad or bad (`reason`,
38+ * required there and refused on ok) and the entry's `props`, checked as
39+ * `log`'s are; it joins the product-wide feature surface, so no prefix.
4040 *
41- * @param entry the feature, how it went, and why when not ok
41+ * @param entry the feature, how it went, why when not ok, and the row's
42+ * properties by snake_case key
4243 * @example
4344 * await $.telemetry.mark({ feature: "learn_page", kind: "ok" })
4445 * await $.telemetry.mark({
4546 * feature: "learn_page",
47+ * kind: "ok",
48+ * props: { page: { value: "later", of: ["ready", "later"] } },
49+ * })
50+ * await $.telemetry.mark({
51+ * feature: "learn_page",
4652 * kind: "sad",
4753 * reason: "blocked",
4854 * })