SLOPSHOPPER

decision-lens

See the decisions Claude made in each turn, why it made them, and steer future turns with keep and avoid rules.

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-lens
│ ┃ Decision Lens ✕ › fix the failing auth test and add an audit log call │ ┃ Decision Lens │ ┃ p: ◀ Earlier turn n: Later turn ▶ d: Decis ⏺ Read(src/auth.ts) │ ┃ ctrl+x tab: use the keys here, or type /why ⎿ Read 6 lines │ ┃ keep 1 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No turns yet. After Claude finishes a turn, ⏺ Bash(bun test) │ ┃ its decisions appear here. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /why │ ⎿ decision-lens: Decision Lens: 0 decisions in the latest turn, 0 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Decision Lens
Decision Lens p: ◀ Earlier turn n: Later turn ▶ d: Decisions r: Rules ( ctrl+x tab: use the keys here, or type /why keep 1 No turns yet. After Claude finishes a turn, its decisions appear here.
README

Decision Lens

See the decisions Claude made in each turn, why it made them, and what it chose not to do. Then steer the next turns: keep a behavior you like, or stop one you do not.

Decision Lens preview

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install decision-lens@claude-code-mods

Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.

How it works

The lens records each turn as Claude works: what Claude wrote, any thinking the model shares, and every tool call with its result. When the turn ends, a helper model reads that trace and names up to five real decisions. The status line then shows ◆ 3 decisions · /why.

Each decision is a card with:

  • what Claude did, and why the trace says it did it,
  • the options it did not take,
  • a short quote or fact from the trace as evidence,
  • how sure the reading is (three dots).

How to use

  1. Work with Claude as usual. After a turn with real work, the status line shows ◆ 3 decisions · /why.
  2. Type /why. The lens opens on the latest turn and takes the keys.
  3. Read the cards. Press a key from the hint row to steer, or press Esc to go back to the prompt. The pane stays open.

Commands work at any time, also while the pane does not have the keys:

  • /why: open the lens on the latest turn.
  • /why keep 2: save a rule from card 2 that tells Claude to repeat the behavior.
  • /why avoid 2: save a rule from card 2 that tells Claude to stop.
  • /why ask 2: ask Claude, through the session's own model and cached conversation, to explain card 2 in its own words. The answer appears on the card.
  • /why rules: open the Rules tab.

Rules go into Claude's system prompt from the next request and in every later session. They are saved for this project. No repository files change.

How to interact

The hint row under the title always names the keys that work now.

KeyWhat it does
1 to 5Pick a card. The picked card has a bright frame, and its buttons show their keys.
kKeep doing this: save the picked card's keep rule.
aDon't do this: save the picked card's avoid rule.
wAsk why: Claude explains the picked card in its own words.
p, nShow the earlier or later turn (the last 20 turns).
d, rShow the Decisions tab or the Rules tab.
xClose the lens.
Tab, EnterMove to the next button, and press it.
EscGive the keys back to the prompt. The pane stays open.
ctrl+x tabGive the keys to the pane again.

On the Rules tab, each rule is numbered and has two buttons: "Make global" (or "This project only") and "Remove". Use Tab and Enter to press them.

Settings

Change these in /plugin:

  • explainTurns (on): read each turn with the helper model. This uses extra tokens.
  • helperModel (claude-sonnet-5-5): the model that reads each turn. Ask why always uses the session's model.
  • helperEffort (medium): how hard the helper model thinks (low, medium, or high).

Some models share no thinking text. The lens then works from what Claude wrote and the tool calls it made.

See the marketplace README for configuration, privacy, updates, and removal.

Source 14 files
hooks/register.ts 80 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { installCapture } from './capture'
4import { ANALYSIS_SYSTEM, analysisPrompt, parseDecisions } from './lens/analysis'
5import { helperFrom } from './lens/helper'
6import { rulesFromStore, rulesSection, RULES_SECTION_ID, storeKeyFor } from './lens/rules'
7import { configureLens, finishAnalysis, lensView, nextAnalysis, setRules } from './lens/state'
8import { summaryLine } from './lens/view'
9import { installPane } from './pane'
10import { noop } from './shared/noop'
11
12const ANALYSIS_POLL_MS = 1500
13
14let isAnalyzing = false
15
16function projectKey(cwd: string): string {
17  return cwd.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
18}
19
20async function loadRules($: EngineInterface, options: { cwd: string }): Promise<void> {
21  const project = projectKey(options.cwd)
22  const projectRules = rulesFromStore(await $.store.get(storeKeyFor({ scope: 'project', project })))
23  const globalRules = rulesFromStore(await $.store.get(storeKeyFor({ scope: 'global', project })))
24  setRules({ project, rules: [...globalRules, ...projectRules] })
25}
26
27async function analyzeNext($: EngineInterface): Promise<void> {
28  if (isAnalyzing) {
29    return
30  }
31  const turn = nextAnalysis()
32  if (!turn) {
33    return
34  }
35  isAnalyzing = true
36  const result = await $.model
37    .complete({
38      model: lensView().helper.helperModel,
39      effort: lensView().helper.helperEffort,
40      system: ANALYSIS_SYSTEM,
41      prompt: analysisPrompt(turn),
42      maxTokens: 1800,
43      timeoutMs: 45000,
44    })
45    .catch(() => undefined)
46  finishAnalysis({ id: turn.id, decisions: result?.isAnswered ? parseDecisions(result.text) : null })
47  isAnalyzing = false
48  $.ui.status(summaryLine(turn))
49  $.ui.invalidate('ui.render')
50}
51
52export const register: Register = (on, options) => {
53  configureLens({ helper: helperFrom(options), isAnalysisOn: options.explainTurns !== false })
54  installCapture(on)
55  installPane(on)
56
57  on('session.start', async ($, e, next) => {
58    await $.command.register({
59      name: 'why',
60      description: 'Decision Lens: why Claude made its last decisions. keep N or avoid N saves a rule from card N, ask N asks Claude why, rules lists your rules.',
61      argumentHint: '[keep N|avoid N|ask N|rules]',
62      immediate: true,
63    })
64    await loadRules($, { cwd: e.cwd })
65    $.clock.every(ANALYSIS_POLL_MS, () => {
66      analyzeNext($).catch(noop)
67    })
68    return next(e)
69  })
70
71  on('prompt.compose', async ($, e, next) => {
72    const composed = await next(e)
73    const text = rulesSection(lensView().rules)
74    if (text === undefined) {
75      return composed
76    }
77    return { sections: [...composed.sections, { id: RULES_SECTION_ID, text, scope: 'session' }] }
78  })
79}
80
hooks/capture.ts 70 lines
1import type { On, TurnStepChunk } from 'claude-code'
2
3import { completeLensTurn, noteNarration, noteToolEnd, noteToolStart, startLensTurn } from './lens/state'
4import { toolSummary } from './lens/trace'
5
6type PendingBlock = { kind: 'said' | 'thought'; index: number; text: string }
7
8function flush(pending: PendingBlock[]): PendingBlock[] {
9  pending.forEach(block => noteNarration({ kind: block.kind, text: block.text }))
10  return []
11}
12
13function absorb(options: { chunk: TurnStepChunk; pending: PendingBlock[] }): PendingBlock[] {
14  const { chunk, pending } = options
15  if (chunk.kind === 'tool') {
16    return flush(pending)
17  }
18  if (chunk.kind !== 'text' && chunk.kind !== 'thinking') {
19    return pending
20  }
21  const kind = chunk.kind === 'text' ? 'said' : 'thought'
22  const last = pending.at(-1)
23  if (last && last.kind === kind && last.index === chunk.index) {
24    last.text += chunk.text
25    return pending
26  }
27  return [...pending, { kind, index: chunk.index, text: chunk.text }]
28}
29
30export function installCapture(on: On): void {
31  on('turn.start', ($, e, next) => {
32    startLensTurn({ turnId: e.turnId, prompt: e.text })
33    return next(e)
34  })
35
36  on('turn.step', async function* ($, e, next) {
37    const stream = next(e)
38    let pending: PendingBlock[] = []
39    for await (const chunk of stream) {
40      if (e.agentId === undefined) {
41        pending = absorb({ chunk, pending })
42      }
43      yield chunk
44    }
45    flush(pending)
46    return await stream.result
47  })
48
49  on('tool.call', async ($, e, next) => {
50    if (e.agentId !== undefined) {
51      return next(e)
52    }
53    noteToolStart({ toolUseId: e.tool_use_id, summary: toolSummary({ tool: String(e.tool), input: e }) })
54    const ran = await next(e)
55    const outcome = ran.deny !== undefined ? 'denied' : ran.isError === true ? 'error' : 'ok'
56    noteToolEnd({ toolUseId: e.tool_use_id, outcome })
57    return ran
58  })
59
60  on('turn.complete', ($, e, next) => {
61    if (e.agentId === undefined) {
62      const turn = completeLensTurn({ answer: e.answer })
63      if (turn?.status === 'waiting') {
64        $.ui.status('◆ reading the decisions in this turn')
65      }
66    }
67    return next(e)
68  })
69}
70
hooks/lens/analysis.ts 88 lines
1import { traceText } from './trace'
2import type { Confidence, Decision, LensTurn } from './types'
3
4export const ANALYSIS_SYSTEM =
5  'You review the work trace of an AI coding agent and name the real decisions it made. You are precise, fair, and brief. Reply with one JSON object and nothing else.'
6
7export function analysisPrompt(turn: LensTurn): string {
8  return [
9    `The user asked: ${turn.prompt}`,
10    '',
11    'Trace of the agent turn, in order:',
12    traceText(turn.trace) || '- (no tool calls or narration were recorded)',
13    '',
14    `Final answer (excerpt): ${turn.answer.slice(0, 1500) || '(none)'}`,
15    '',
16    'Name up to 5 decisions that shaped the result: approach, scope, which files or tools, assumptions, and trade-offs.',
17    'Skip routine steps such as reading a file to understand it. If there were no real decisions, return {"decisions": []}.',
18    'Return JSON with this shape:',
19    '{"decisions": [{',
20    ' "title": "the decision in at most 8 words",',
21    ' "choice": "what the agent did, one sentence",',
22    ' "why": "the reason the trace shows, one or two sentences; say so if the trace shows no reason",',
23    ' "alternatives": ["a real option it did not take", "another"],',
24    ' "evidence": "a short quote or fact from the trace",',
25    ' "confidence": "high, medium, or low: how sure you are that this reading is right",',
26    ' "keep": "an imperative rule that would make the agent do this again, at most 20 words",',
27    ' "avoid": "an imperative rule that would stop the agent doing this, at most 20 words"',
28    '}]}',
29  ].join('\n')
30}
31
32function isRecord(value: unknown): value is Record<string, unknown> {
33  return typeof value === 'object' && value !== null && !Array.isArray(value)
34}
35
36function text(options: { value: unknown; limit: number }): string {
37  return typeof options.value === 'string' ? options.value.replace(/\s+/g, ' ').trim().slice(0, options.limit) : ''
38}
39
40function confidenceOf(value: unknown): Confidence {
41  return value === 'high' || value === 'low' ? value : 'medium'
42}
43
44export function parseDecisions(reply: string): Decision[] | null {
45  const start = reply.indexOf('{')
46  const end = reply.lastIndexOf('}')
47  if (start < 0 || end <= start) {
48    return null
49  }
50  let parsed: unknown
51  try {
52    parsed = JSON.parse(reply.slice(start, end + 1))
53  } catch {
54    return null
55  }
56  if (!isRecord(parsed) || !Array.isArray(parsed.decisions)) {
57    return null
58  }
59  return parsed.decisions
60    .filter(isRecord)
61    .map((decision, index) => ({
62      id: index + 1,
63      title: text({ value: decision.title, limit: 80 }),
64      choice: text({ value: decision.choice, limit: 300 }),
65      why: text({ value: decision.why, limit: 400 }),
66      alternatives: Array.isArray(decision.alternatives)
67        ? decision.alternatives.map(alternative => text({ value: alternative, limit: 160 })).filter(Boolean).slice(0, 3)
68        : [],
69      evidence: text({ value: decision.evidence, limit: 240 }),
70      confidence: confidenceOf(decision.confidence),
71      keep: text({ value: decision.keep, limit: 200 }),
72      avoid: text({ value: decision.avoid, limit: 200 }),
73      explanation: '',
74      isAsking: false,
75    }))
76    .filter(decision => decision.title !== '' && decision.choice !== '')
77    .slice(0, 5)
78}
79
80export function askWhyPrompt(decision: Decision): string {
81  const alternatives = decision.alternatives.length > 0 ? ` instead of ${decision.alternatives.join(', or ')}` : ''
82  return [
83    `Earlier in this conversation you made this decision: ${decision.choice}${alternatives}.`,
84    'In three to five sentences, explain why. Name what you saw in the code or the conversation that led you there,',
85    'what you were optimizing for, and what would have made you choose differently. Plain prose, no lists, no headings.',
86  ].join(' ')
87}
88
hooks/lens/helper.ts 16 lines
1import type { ModelEffort, PluginOptions } from 'claude-code'
2
3export const DEFAULT_HELPER_MODEL = 'claude-sonnet-5-5'
4
5const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high']
6
7export type Helper = { helperModel: string; helperEffort: ModelEffort }
8
9export function helperFrom(options: PluginOptions): Helper {
10  const model = options.helperModel
11  return {
12    helperModel: typeof model === 'string' && model.trim() !== '' ? model.trim() : DEFAULT_HELPER_MODEL,
13    helperEffort: EFFORTS.find(effort => effort === options.helperEffort) ?? 'medium',
14  }
15}
16
hooks/lens/rules.ts 55 lines
1import type { Rule, RuleKind, RuleScope } from './types'
2
3export const RULES_SECTION_ID = 'decision-lens:rules'
4
5function isRule(value: unknown): value is Rule {
6  return (
7    typeof value === 'object' &&
8    value !== null &&
9    'id' in value &&
10    typeof value.id === 'string' &&
11    'text' in value &&
12    typeof value.text === 'string' &&
13    'kind' in value &&
14    (value.kind === 'keep' || value.kind === 'avoid') &&
15    'scope' in value &&
16    (value.scope === 'project' || value.scope === 'global') &&
17    'createdAt' in value &&
18    typeof value.createdAt === 'number'
19  )
20}
21
22export function rulesFromStore(value: unknown): Rule[] {
23  return Array.isArray(value) ? value.filter(isRule) : []
24}
25
26export function storeKeyFor(options: { scope: RuleScope; project: string }): string {
27  return options.scope === 'global' ? 'rules:global' : `rules:project:${options.project}`
28}
29
30export function newRule(options: { kind: RuleKind; text: string; scope: RuleScope; at: number; existing: readonly Rule[] }): Rule {
31  return {
32    id: `${options.at.toString(36)}-${options.existing.length}`,
33    kind: options.kind,
34    text: options.text.replace(/\s+/g, ' ').trim().slice(0, 240),
35    scope: options.scope,
36    createdAt: options.at,
37  }
38}
39
40export function rulesSection(rules: readonly Rule[]): string | undefined {
41  if (rules.length === 0) {
42    return undefined
43  }
44  const keep = rules.filter(rule => rule.kind === 'keep').map(rule => `- ${rule.text}`)
45  const avoid = rules.filter(rule => rule.kind === 'avoid').map(rule => `- ${rule.text}`)
46  return [
47    '# Steering rules from the user',
48    'The user set these rules with the Decision Lens after reviewing your past decisions. Follow them unless the user asks for something else in this conversation.',
49    keep.length > 0 ? `\nKeep doing:\n${keep.join('\n')}` : '',
50    avoid.length > 0 ? `\nAvoid:\n${avoid.join('\n')}` : '',
51  ]
52    .filter(Boolean)
53    .join('\n')
54}
55
hooks/lens/state.ts 181 lines
1import { DEFAULT_HELPER_MODEL } from './helper'
2import type { Helper } from './helper'
3import { compact } from './trace'
4import type { Decision, LensTab, LensTurn, Rule, ToolOutcome } from './types'
5
6const MAX_TURNS = 20
7const MIN_NARRATION_FOR_ANALYSIS = 160
8
9type Lens = {
10  turns: LensTurn[]
11  currentId: number | undefined
12  selectedId: number | undefined
13  isFollowing: boolean
14  selectedNumber: number
15  tab: LensTab
16  rules: Rule[]
17  project: string
18  helper: Helper
19  isAnalysisOn: boolean
20  nextId: number
21}
22
23const lens: Lens = {
24  turns: [],
25  currentId: undefined,
26  selectedId: undefined,
27  isFollowing: true,
28  selectedNumber: 1,
29  tab: 'decisions',
30  rules: [],
31  project: '',
32  helper: { helperModel: DEFAULT_HELPER_MODEL, helperEffort: 'medium' },
33  isAnalysisOn: true,
34  nextId: 1,
35}
36
37export function configureLens(options: { helper: Helper; isAnalysisOn: boolean }): void {
38  lens.helper = options.helper
39  lens.isAnalysisOn = options.isAnalysisOn
40}
41
42export function lensView(): Readonly<Lens> {
43  return lens
44}
45
46export function currentTurn(): LensTurn | undefined {
47  return lens.turns.find(turn => turn.id === lens.currentId)
48}
49
50export function selectedTurn(): LensTurn | undefined {
51  return lens.turns.find(turn => turn.id === lens.selectedId) ?? lens.turns.at(-1)
52}
53
54export function startLensTurn(options: { turnId: string; prompt: string }): void {
55  const turn: LensTurn = {
56    id: lens.nextId,
57    turnId: options.turnId,
58    prompt: compact(options.prompt),
59    trace: [],
60    answer: '',
61    status: 'recording',
62    decisions: [],
63  }
64  lens.nextId += 1
65  lens.turns = [...lens.turns, turn].slice(-MAX_TURNS)
66  lens.currentId = turn.id
67}
68
69export function noteNarration(options: { kind: 'said' | 'thought'; text: string }): void {
70  const text = compact(options.text)
71  if (text !== '') {
72    currentTurn()?.trace.push({ kind: options.kind, text, toolUseId: '', outcome: 'ok' })
73  }
74}
75
76export function noteToolStart(options: { toolUseId: string; summary: string }): void {
77  currentTurn()?.trace.push({ kind: 'tool', text: compact(options.summary), toolUseId: options.toolUseId, outcome: 'running' })
78}
79
80export function noteToolEnd(options: { toolUseId: string; outcome: ToolOutcome }): void {
81  const entry = currentTurn()?.trace.find(candidate => candidate.toolUseId === options.toolUseId)
82  if (entry) {
83    entry.outcome = options.outcome
84  }
85}
86
87export function completeLensTurn(options: { answer: string }): LensTurn | undefined {
88  const turn = currentTurn()
89  if (!turn) {
90    return undefined
91  }
92  turn.answer = compact(options.answer)
93  const narration = turn.trace.filter(entry => entry.kind !== 'tool').reduce((sum, entry) => sum + entry.text.length, 0)
94  const hasTools = turn.trace.some(entry => entry.kind === 'tool')
95  const isWorthAnalyzing = hasTools || narration + turn.answer.length >= MIN_NARRATION_FOR_ANALYSIS
96  turn.status = lens.isAnalysisOn && isWorthAnalyzing ? 'waiting' : 'skipped'
97  lens.currentId = undefined
98  if (lens.isFollowing) {
99    lens.selectedId = turn.id
100    lens.selectedNumber = 1
101  }
102  return turn
103}
104
105export function nextAnalysis(): LensTurn | undefined {
106  const turn = lens.turns.find(candidate => candidate.status === 'waiting')
107  if (turn) {
108    turn.status = 'running'
109  }
110  return turn
111}
112
113export function finishAnalysis(options: { id: number; decisions: Decision[] | null }): void {
114  const turn = lens.turns.find(candidate => candidate.id === options.id)
115  if (turn) {
116    turn.decisions = options.decisions ?? []
117    turn.status = options.decisions ? 'ready' : 'failed'
118  }
119}
120
121export function moveSelection(step: number): void {
122  const current = selectedTurn()
123  const index = current ? lens.turns.indexOf(current) : lens.turns.length - 1
124  const target = lens.turns[Math.min(lens.turns.length - 1, Math.max(0, index + step))]
125  if (target) {
126    lens.selectedId = target.id
127    lens.isFollowing = target === lens.turns.at(-1)
128    lens.selectedNumber = 1
129  }
130}
131
132export function followLatest(): void {
133  lens.isFollowing = true
134  lens.selectedId = lens.turns.at(-1)?.id
135  lens.selectedNumber = 1
136}
137
138export function selectCard(number: number): void {
139  lens.selectedNumber = number
140}
141
142export function decisionNumbered(number: number): { turn: LensTurn; decision: Decision } | undefined {
143  const turn = selectedTurn()
144  const decision = turn?.status === 'ready' ? turn.decisions[number - 1] : undefined
145  return turn && decision ? { turn, decision } : undefined
146}
147
148export function showTab(tab: LensTab): void {
149  lens.tab = tab
150}
151
152export function findDecision(options: { turnId: number; decisionId: number }): Decision | undefined {
153  return lens.turns.find(turn => turn.id === options.turnId)?.decisions.find(decision => decision.id === options.decisionId)
154}
155
156export function markAsking(options: { turnId: number; decisionId: number }): Decision | undefined {
157  const decision = findDecision(options)
158  if (decision) {
159    decision.isAsking = true
160    decision.explanation = ''
161  }
162  return decision
163}
164
165export function setExplanation(options: { turnId: number; decisionId: number; text: string }): void {
166  const decision = findDecision(options)
167  if (decision) {
168    decision.isAsking = false
169    decision.explanation = options.text
170  }
171}
172
173export function setRules(options: { project: string; rules: Rule[] }): void {
174  lens.project = options.project
175  lens.rules = options.rules
176}
177
178export function replaceRules(rules: Rule[]): void {
179  lens.rules = rules
180}
181
hooks/lens/view.ts 57 lines
1import type { Confidence, Decision, LensTurn, Rule } from './types'
2
3export const COLORS = {
4  accent: '#a78bfa',
5  keep: '#34d399',
6  avoid: '#fb7185',
7  muted: '#8b93a1',
8  frame: '#4b5563',
9} as const
10
11const CONFIDENCE_GLYPHS: Record<Confidence, string> = { high: '●●●', medium: '●●○', low: '●○○' }
12
13export function confidenceGlyph(confidence: Confidence): string {
14  return CONFIDENCE_GLYPHS[confidence]
15}
16
17export function turnMessage(options: { turn: LensTurn | undefined; isAnalysisOn: boolean }): string | undefined {
18  const { turn } = options
19  if (!turn) {
20    return 'No turns yet. After Claude finishes a turn, its decisions appear here.'
21  }
22  if (turn.status === 'recording') {
23    return 'Claude is still working on this turn.'
24  }
25  if (turn.status === 'waiting' || turn.status === 'running') {
26    return 'Reading the decisions in this turn.'
27  }
28  if (turn.status === 'failed') {
29    return 'The helper model did not answer, so decisions for this turn are not available.'
30  }
31  if (turn.status === 'skipped') {
32    return options.isAnalysisOn
33      ? 'This turn had no tool calls or real choices to explain.'
34      : 'Turn analysis is off. Turn on "Explain each turn" in /plugin.'
35  }
36  return turn.decisions.length === 0 ? 'No real decisions in this turn. Claude took the obvious path.' : undefined
37}
38
39export function hasRule(options: { rules: readonly Rule[]; text: string }): boolean {
40  return options.rules.some(rule => rule.text === options.text)
41}
42
43export function decisionBody(decision: Decision): { label: string; text: string }[] {
44  return [
45    { label: 'Did', text: decision.choice },
46    { label: 'Why', text: decision.why },
47  ].filter(part => part.text !== '')
48}
49
50export function summaryLine(turn: LensTurn | undefined): string | undefined {
51  if (!turn || turn.status !== 'ready') {
52    return undefined
53  }
54  const count = turn.decisions.length
55  return count === 0 ? undefined : `◆ ${count} decision${count === 1 ? '' : 's'} · /why`
56}
57
hooks/pane.tsx 177 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { askWhyPrompt } from './lens/analysis'
4import { parseWhy, WHY_USAGE } from './lens/command'
5import type { SteerKind } from './lens/command'
6import { cardTree, headerTree, hintText, ruleTree } from './lens/panel'
7import type { PaneAction } from './lens/panel'
8import { newRule, storeKeyFor } from './lens/rules'
9import {
10  decisionNumbered,
11  findDecision,
12  followLatest,
13  lensView,
14  markAsking,
15  moveSelection,
16  replaceRules,
17  selectCard,
18  selectedTurn,
19  setExplanation,
20  showTab,
21} from './lens/state'
22import type { Rule, RuleKind } from './lens/types'
23import { hasRule, turnMessage } from './lens/view'
24import { noop } from './shared/noop'
25
26export const PANE_ID = 'decision-lens'
27
28async function persistRules($: EngineInterface): Promise<void> {
29  const { rules, project } = lensView()
30  await $.store.set(storeKeyFor({ scope: 'project', project }), rules.filter(rule => rule.scope === 'project'))
31  await $.store.set(storeKeyFor({ scope: 'global', project }), rules.filter(rule => rule.scope === 'global'))
32}
33
34async function addRule($: EngineInterface, options: { kind: RuleKind; text: string }): Promise<boolean> {
35  const rules = lensView().rules
36  if (options.text === '' || hasRule({ rules, text: options.text })) {
37    return false
38  }
39  const rule = newRule({ kind: options.kind, text: options.text, scope: 'project', at: await $.clock.now(), existing: rules })
40  replaceRules([...rules, rule])
41  await persistRules($)
42  $.ui.toast(`${options.kind === 'keep' ? 'Keep' : 'Avoid'} rule saved. Claude reads it from the next request.`)
43  return true
44}
45
46async function changeRules($: EngineInterface, options: { rules: Rule[] }): Promise<void> {
47  replaceRules(options.rules)
48  await persistRules($)
49}
50
51async function askWhy($: EngineInterface, options: { turnId: number; decisionId: number }): Promise<void> {
52  const decision = markAsking(options)
53  if (!decision) {
54    return
55  }
56  $.ui.invalidate('ui.render')
57  const result = await $.model.fork({ prompt: askWhyPrompt(decision) }).catch(() => undefined)
58  const text = result?.isAnswered ? result.text.trim() : 'Claude could not answer right now. Try again after the current turn.'
59  setExplanation({ ...options, text })
60  $.ui.invalidate('ui.render')
61}
62
63function ruleChange(action: { kind: 'scope' | 'remove'; ruleId: string }): Rule[] {
64  const { rules } = lensView()
65  if (action.kind === 'remove') {
66    return rules.filter(rule => rule.id !== action.ruleId)
67  }
68  return rules.map(rule =>
69    rule.id === action.ruleId ? { ...rule, scope: rule.scope === 'project' ? 'global' : 'project' } : rule,
70  )
71}
72
73async function perform($: EngineInterface, options: { action: PaneAction }): Promise<void> {
74  const { action } = options
75  if (action.kind === 'select') {
76    selectCard(action.number)
77  } else if (action.kind === 'turn') {
78    moveSelection(action.step)
79  } else if (action.kind === 'tab') {
80    showTab(action.tab)
81  } else if (action.kind === 'close') {
82    await $.ui.close({ id: PANE_ID })
83    return
84  } else if (action.kind === 'scope' || action.kind === 'remove') {
85    await changeRules($, { rules: ruleChange(action) })
86  } else if (action.kind === 'ask') {
87    await askWhy($, { turnId: action.turnId, decisionId: action.decisionId })
88  } else if (action.kind === 'keep' || action.kind === 'avoid') {
89    const decision = findDecision({ turnId: action.turnId, decisionId: action.decisionId })
90    if (decision) {
91      await addRule($, { kind: action.kind, text: action.kind === 'keep' ? decision.keep : decision.avoid })
92    }
93  }
94  $.ui.invalidate('ui.render')
95}
96
97async function steer($: EngineInterface, options: { steer: SteerKind; number: number }): Promise<string> {
98  const numbered = decisionNumbered(options.number)
99  if (!numbered) {
100    return `There is no card ${options.number} on the turn shown in /why. ${WHY_USAGE}`
101  }
102  const { turn, decision } = numbered
103  selectCard(options.number)
104  if (options.steer === 'ask') {
105    askWhy($, { turnId: turn.id, decisionId: decision.id }).catch(noop)
106    await $.ui.open({ id: PANE_ID, title: 'Decision Lens', focus: true, rows: 30 })
107    return `Asking Claude why it chose "${decision.title}". The answer appears on card ${options.number} in /why.`
108  }
109  const text = options.steer === 'keep' ? decision.keep : decision.avoid
110  const isAdded = await addRule($, { kind: options.steer, text })
111  $.ui.invalidate('ui.render')
112  return isAdded ? `Saved ${options.steer} rule: ${text}` : `That rule is already saved: ${text}`
113}
114
115export function installPane(on: On): void {
116  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
117    const elements = $.ui.resolve(e)
118    const { Box, Text } = elements
119    const view = lensView()
120    const turn = selectedTurn()
121    const act = (action: PaneAction) => {
122      perform($, { action }).catch(noop)
123    }
124    const hasCards = view.tab === 'decisions' && turn?.status === 'ready' && turn.decisions.length > 0
125    const position = view.tab === 'decisions' && turn ? `  turn ${view.turns.indexOf(turn) + 1} of ${view.turns.length}` : ''
126    const message = view.tab === 'decisions' ? turnMessage({ turn, isAnalysisOn: view.isAnalysisOn }) : undefined
127    const header = headerTree({
128      elements,
129      act,
130      tab: view.tab,
131      position,
132      ruleCount: view.rules.length,
133      hint: hintText({ isFocused: e.props.isFocused, tab: view.tab }),
134    })
135    if (view.tab === 'rules') {
136      return (
137        <Box flexDirection="column">
138          {header}
139          <Text dimColor>Claude reads these rules in every session: project rules here, global rules everywhere.</Text>
140          {view.rules.length === 0 && <Text dimColor>No rules yet. Press k or a on a decision card, or type /why keep 1.</Text>}
141          {view.rules.map((rule, index) => ruleTree({ elements, act, rule, number: index + 1 }))}
142        </Box>
143      )
144    }
145    return (
146      <Box flexDirection="column">
147        {header}
148        {turn && <Text italic dimColor>{`“${turn.prompt}”`}</Text>}
149        {message !== undefined && <Text dimColor>{message}</Text>}
150        {hasCards &&
151          turn.decisions.map((decision, index) =>
152            cardTree({ elements, act, turn, decision, number: index + 1, isSelected: index + 1 === view.selectedNumber, rules: view.rules }),
153          )}
154      </Box>
155    )
156  })
157
158  on('command.run', { command: 'why' }, async ($, e) => {
159    const command = parseWhy(e.args)
160    if (command.kind === 'unknown') {
161      return { text: WHY_USAGE }
162    }
163    if (command.kind === 'steer') {
164      return { text: await steer($, { steer: command.steer, number: command.number }) }
165    }
166    showTab(command.tab)
167    followLatest()
168    await $.ui.open({ id: PANE_ID, title: 'Decision Lens', focus: true, rows: 30 })
169    $.ui.invalidate('ui.render')
170    const turn = selectedTurn()
171    const count = turn?.status === 'ready' ? turn.decisions.length : 0
172    return {
173      text: `Decision Lens: ${count} decisions in the latest turn, ${lensView().rules.length} steering rules. The pane has the keys now; Esc gives them back.`,
174    }
175  })
176}
177
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4
hooks/lens/trace.ts 40 lines
1import { sanitize } from '../shared/privacy'
2import type { TraceEntry } from './types'
3
4const MAX_ENTRY = 600
5const MAX_TRACE = 14000
6
7function firstString(input: Readonly<Record<string, unknown>>): string {
8  const preferred = ['file_path', 'notebook_path', 'command', 'pattern', 'description', 'url', 'query', 'prompt']
9  for (const key of preferred) {
10    const value = input[key]
11    if (typeof value === 'string' && value !== '') {
12      return value
13    }
14  }
15  const fallback = Object.values(input).find((value): value is string => typeof value === 'string')
16  return fallback ?? ''
17}
18
19export function toolSummary(options: { tool: string; input: Readonly<Record<string, unknown>> }): string {
20  const detail = firstString(options.input).split('\n')[0] ?? ''
21  const short = detail.length > 120 ? detail.slice(0, 119) + '…' : detail
22  return short === '' ? options.tool : `${options.tool} ${short}`
23}
24
25export function compact(text: string): string {
26  const cleaned = sanitize(text).replace(/\s+/g, ' ').trim()
27  return cleaned.length > MAX_ENTRY ? cleaned.slice(0, MAX_ENTRY - 1) + '…' : cleaned
28}
29
30export function traceText(trace: readonly TraceEntry[]): string {
31  const lines = trace.map(entry => {
32    if (entry.kind === 'tool') {
33      return `- Tool call: ${entry.text} -> ${entry.outcome}`
34    }
35    return entry.kind === 'thought' ? `- Claude thought: ${entry.text}` : `- Claude said: ${entry.text}`
36  })
37  const joined = lines.join('\n')
38  return joined.length > MAX_TRACE ? '…\n' + joined.slice(-MAX_TRACE) : joined
39}
40
hooks/lens/types.ts 53 lines
1export type TraceKind = 'said' | 'thought' | 'tool'
2
3export type ToolOutcome = 'running' | 'ok' | 'error' | 'denied'
4
5export type TraceEntry = {
6  kind: TraceKind
7  text: string
8  toolUseId: string
9  outcome: ToolOutcome
10}
11
12export type Confidence = 'high' | 'medium' | 'low'
13
14export type Decision = {
15  id: number
16  title: string
17  choice: string
18  why: string
19  alternatives: string[]
20  evidence: string
21  confidence: Confidence
22  keep: string
23  avoid: string
24  explanation: string
25  isAsking: boolean
26}
27
28export type AnalysisStatus = 'recording' | 'waiting' | 'running' | 'ready' | 'failed' | 'skipped'
29
30export type LensTurn = {
31  id: number
32  turnId: string
33  prompt: string
34  trace: TraceEntry[]
35  answer: string
36  status: AnalysisStatus
37  decisions: Decision[]
38}
39
40export type RuleKind = 'keep' | 'avoid'
41
42export type RuleScope = 'project' | 'global'
43
44export type Rule = {
45  id: string
46  kind: RuleKind
47  text: string
48  scope: RuleScope
49  createdAt: number
50}
51
52export type LensTab = 'decisions' | 'rules'
53
hooks/lens/command.ts 30 lines
1import type { LensTab, RuleKind } from './types'
2
3export type SteerKind = RuleKind | 'ask'
4
5export type WhyCommand =
6  | { kind: 'open'; tab: LensTab }
7  | { kind: 'steer'; steer: SteerKind; number: number }
8  | { kind: 'unknown' }
9
10const STEERS: readonly SteerKind[] = ['keep', 'avoid', 'ask']
11
12export const WHY_USAGE =
13  'Use /why to open the lens, /why rules for your rules, or /why keep N, /why avoid N, /why ask N, where N is a card number.'
14
15export function parseWhy(args: string): WhyCommand {
16  const [word = '', count = '1'] = args.trim().toLowerCase().split(/\s+/)
17  if (word === '') {
18    return { kind: 'open', tab: 'decisions' }
19  }
20  if (word === 'rules') {
21    return { kind: 'open', tab: 'rules' }
22  }
23  const steer = STEERS.find(candidate => candidate === word)
24  const number = Number(count)
25  if (steer === undefined || !Number.isInteger(number) || number < 1) {
26    return { kind: 'unknown' }
27  }
28  return { kind: 'steer', steer, number }
29}
30