eklavya 1.71.0 → 1.71.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,7 +52,7 @@ Code's learning loop.
52
52
  |---|---|
53
53
  | Learner state and concepts | `get_learner_profile`, `log_session_concepts`, `upsert_concepts`, `get_concept_graph` |
54
54
  | Questions and assessment | `get_session_quiz_plan`, `record_attempt`, `get_gate_status` |
55
- | Quiz side panel (experimental) | `present_question` for the model; `panel_sync` and `panel_answer`, called only by the panel mod, never by the model |
55
+ | Quiz side panel (experimental) | `present_question` for the model; `panel_sync` and `panel_answer`, called only by the panel mod, never by the model; its header stacks the mark above the topic in a pane under 60 cells wide |
56
56
  | Settings | `get_config`, `set_config` |
57
57
  | Find and read memory | `memory_search`, `memory_get`, `memory_timeline`, `memory_file_history`, `memory_status` |
58
58
  | Maintain memory | `memory_write`, `memory_correct`, `memory_delete`, `memory_collections` |
@@ -83,7 +83,7 @@ level.
83
83
  right one, 2 for a near miss, 1 for a misconception), the right one in the
84
84
  plan's `answer_position` slot, and an explanation. It stores the question and
85
85
  returns at once; it writes no attempt. The panel records the answer through
86
- `panel_answer` with the same code `record_attempt` uses, exactly once. `kind: "dont_know"` (the panel's **Teach me** button) records `dont_know` with grade 0, replies with the right answer and `taught: true`, and always carries `explain`. On a miss with `explain_on_wrong` on, its reply carries the same `explain` block `record_attempt` returns, and the panel starts the explainer agent from it. `present_question` sends a question back once with `conspicuous_options` (nothing stored) when one option's label plus description outruns the next by a few words or a description explains itself, and presents the same stem as written on the second call. It returns `panel_disabled` while `quiz.panel` is off, and
86
+ `panel_answer` with the same code `record_attempt` uses, exactly once. `kind: "dont_know"` (the panel's **New to me — Teach me** button) records `dont_know` with grade 0, replies with the right answer and `taught: true`, and always carries `explain`. On a miss with `explain_on_wrong` on, its reply carries the same `explain` block `record_attempt` returns, and the panel starts the explainer agent from it. `present_question` sends a question back once with `conspicuous_options` (nothing stored) when one option's label plus description outruns the next by a few words or a description explains itself, and presents the same stem as written on the second call. It returns `panel_disabled` while `quiz.panel` is off, and
87
87
  `panel_sync` returns `{disabled: true}` and expires the session's open question without recording an attempt, so turning the panel back on cannot revive it; `panel_answer` answers only a question
88
88
  that already exists. A round the learner asked for (a topic, `max`) is remembered per session: `present_question` takes each question off it and sets the last one's `more` false, and `get_session_quiz_plan` with `resume_round: true` plans what is left. This is
89
89
  a Claude Code mod feature: a standalone MCP client has the tools but not the
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eklavya",
3
3
  "displayName": "Eklavya",
4
- "version": "1.71.0",
4
+ "version": "1.71.2",
5
5
  "description": "Learn while your agent works. Turns coding-agent generation time into adaptive, Socratic learning grounded in the code being written.",
6
6
  "author": {
7
7
  "name": "Ajay Kumar"
@@ -22,6 +22,8 @@ export const TIER: Record<number, string> = { 1: 'recall', 2: 'mechanism', 3: 'j
22
22
  export const BRAND = '#79D5C4'
23
23
  /** The fill and text of the picked answer: set together so the contrast holds in a light or a dark theme. */
24
24
  export const PICKED = { bg: '#1E5A52', text: '#FFFFFF', note: '#C9E8E3' }
25
+ /** Teach me is Claude's orange (the logo's mascot), fill and border. The text is dark, as white on that orange is too faint at body size. */
26
+ export const TEACH = { bg: '#D97757', text: '#1F1E1D' }
25
27
 
26
28
  /**
27
29
  * The logo: the Claude mascot (orange) holding an Eklavya bow and arrow (white), drawn on a
@@ -106,14 +108,14 @@ export function codify(text: string): string {
106
108
  .join('')
107
109
  }
108
110
 
109
- /** Block Markdown that bold marks around it would break: a fence, heading, list, quote, or bold already in it. */
110
- const NOT_BOLDABLE = /```|\*\*|^\s*(?:#|[-*+>]|\d+[.)])\s/m
111
+ /** Block Markdown a heading mark would break: a fence, heading, list, quote, or bold already in it. */
112
+ const NOT_HEADING = /```|\*\*|^\s*(?:#|[-*+>]|\d+[.)])\s/m
111
113
 
112
- /** Bolds each plain paragraph of a question, so a blank line never sits inside a bold span. */
113
- export function boldParagraphs(text: string): string {
114
+ /** Makes each plain paragraph of a question a heading, so the app draws it larger and bold; a heading is one line, so its line breaks become spaces. */
115
+ export function headingParagraphs(text: string): string {
114
116
  return text
115
117
  .split(/\n{2,}/)
116
- .map(p => (p.trim() && !NOT_BOLDABLE.test(p) ? `**${p.trim()}**` : p))
118
+ .map(p => (p.trim() && !NOT_HEADING.test(p) ? `### ${p.trim().replace(/\s*\n\s*/g, ' ')}` : p))
117
119
  .join('\n\n')
118
120
  }
119
121
 
@@ -132,7 +134,7 @@ export const STR = {
132
134
  brand: 'Eklavya',
133
135
  submit: 'Submit answer',
134
136
  skip: 'Skip question',
135
- teach: 'Teach me',
137
+ teach: 'New to me — Teach me',
136
138
  next: 'Next question',
137
139
  done: 'Done',
138
140
  autoClose: 'Closes by itself in 15 seconds.',
@@ -159,7 +161,6 @@ export const STR = {
159
161
  askedOther: 'Eklavya: a note for Claude',
160
162
  focus: 'Ctrl+X then Tab moves the keys here.',
161
163
  keys: '1–4 pick · o other · s submit · k skip · t teach me · Esc back to prompt',
162
- keysApp: '1–4 pick · o other · s submit · k skip · t teach me',
163
164
  off: 'The quiz panel is off. Turn it on with: eklavya config set quiz.panel true',
164
165
  } as const
165
166
 
@@ -202,11 +203,11 @@ export function explainerBrief(x: ExplainBlock): string {
202
203
  export const answerWas = (label: string): string => `The answer was: ${label}`
203
204
 
204
205
  /**
205
- * The hint lines under the pane. Moving the keys with Ctrl+X then Tab and
206
- * leaving with Esc are the terminal's; an app draws native buttons, so it gets
207
- * the answer keys only.
206
+ * The key hints. Only the terminal gets them: there Ctrl+X then Tab moves the keys into the pane. The
207
+ * desktop app keeps typing in its prompt even after a click on the pane (seen 2026-10-16), so a mod's
208
+ * hotkeys never reach it there, and it shows no keys at all.
208
209
  */
209
- export const hintLines = (surface: string): string[] => (surface === 'terminal' ? [STR.focus, STR.keys] : [STR.keysApp])
210
+ export const hintLines = (surface: string): string[] => (surface === 'terminal' ? [STR.focus, STR.keys] : [])
210
211
 
211
212
  export const unplacedNotice = (): string => `A question is waiting. ${REOPEN} to open it.`
212
213
  export const levelLine = (level: string, project: string): string => `You've cleared ${level} on ${project}.`
@@ -4,6 +4,7 @@ import type { EngineInterface, Register } from 'claude-code'
4
4
  import {
5
5
  BRAND,
6
6
  PICKED,
7
+ TEACH,
7
8
  EMPTY,
8
9
  EXPLAINER,
9
10
  NEXT_PROMPT,
@@ -18,7 +19,7 @@ import {
18
19
  gradingRequest,
19
20
  logoRows,
20
21
  boxRows,
21
- boldParagraphs,
22
+ headingParagraphs,
22
23
  plainLabel,
23
24
  levelLine,
24
25
  parseVerdict,
@@ -42,6 +43,9 @@ import type { PanelPending, PanelQuestion, PanelState } from './types'
42
43
  * for the next sync.
43
44
  */
44
45
 
46
+ // Below this many cells across, the header stacks the mark over the topic (the pane reports bodyColumns).
47
+ const NARROW = 60
48
+
45
49
  const REF = { plugin: 'eklavya', key: 'quiz' } as const
46
50
  const quiz = atom(REF, EMPTY)
47
51
 
@@ -364,8 +368,8 @@ export const register: Register = on => {
364
368
  const s: PanelState = await read($, quiz)
365
369
  const q = s.question
366
370
 
367
- // Any surface but the terminal is the desktop app's: it sets the question bold, spaces the answers
368
- // apart and draws the mark in boxes. The terminal keeps its text layout.
371
+ // Any surface but the terminal is the desktop app's: it sets the question as a heading, spaces the
372
+ // answers apart and draws the mark in boxes. The terminal keeps its text layout.
369
373
  const app = e.surface !== 'terminal'
370
374
  // The mark is drawn in text, as Claude Code draws its own, so it shows in every terminal. The
371
375
  // desktop app sets text in a proportional font with its own line height, so glyph cells do not
@@ -396,19 +400,23 @@ export const register: Register = on => {
396
400
  ))}
397
401
  </Box>
398
402
  )
399
- // A list tile: the mark on the left; the name over the topic beside it.
403
+ // A list tile: the mark on the left, the name over the topic beside it. A pane narrower than
404
+ // NARROW cells stacks them, so the topic gets the full width instead of a sliver.
405
+ const stacked = ((e.props as { bodyColumns?: number }).bodyColumns ?? Infinity) < NARROW
400
406
  const head = (subtitle?: string) => (
401
- <Box flexDirection="row" alignItems="center" gap={2}>
407
+ <Box flexDirection={stacked ? 'column' : 'row'} alignItems={stacked ? 'flex-start' : 'center'} gap={2}>
402
408
  {logo}
403
409
  <Box flexDirection="column" flexShrink={1}>
404
410
  <Text bold color={BRAND}>
405
411
  {WORDMARK}
406
412
  </Text>
407
- {subtitle ? <Text>{subtitle}</Text> : null}
413
+ {subtitle ? <Text wrap="wrap">{subtitle}</Text> : null}
408
414
  </Box>
409
415
  </Box>
410
416
  )
411
- const hint = (
417
+ // The desktop app never sends the pane its keys (see hintLines), so it gets no hotkeys and no hints.
418
+ const hk = (k: string) => (app ? undefined : k)
419
+ const hint = app ? null : (
412
420
  <Box flexDirection="column">
413
421
  {hintLines(e.surface).map(line => (
414
422
  <Text key={line} dimColor>
@@ -418,8 +426,9 @@ export const register: Register = on => {
418
426
  </Box>
419
427
  )
420
428
  // One answer: a bordered block. The Button holds the whole answer text, so a press anywhere on it
421
- // selects; the border and a tick show the pick, and nothing implies it is right. The desktop app gives
422
- // the text more room inside the border; the terminal keeps its tight cards.
429
+ // selects; the border and fill show the pick, and nothing implies it is
430
+ // right. The desktop app gives the text more room inside the border; the padding itself is not
431
+ // pressable, since a Button takes none. The terminal keeps its tight cards.
423
432
  // Other is never filled: the learner types inside it, in the host's own text colour, which a fixed fill could hide.
424
433
  const card = (id: string, selected: boolean, button: unknown, body: unknown[], roomy = false) => (
425
434
  <Box
@@ -455,17 +464,17 @@ export const register: Register = on => {
455
464
  )
456
465
  }
457
466
 
458
- const stem = <Markdown text={app ? boldParagraphs(codify(q.stem)) : codify(q.stem)} />
467
+ const stem = <Markdown text={app ? headingParagraphs(codify(q.stem)) : codify(q.stem)} />
459
468
 
460
469
  if (s.step === 'feedback' || s.step === 'skipped') {
461
470
  const r = s.result
462
471
  if (s.step === 'skipped' || !r) {
463
- return frame(<Text>{STR.skipped}</Text>, q.more ? null : <Text dimColor>{STR.autoClose}</Text>, <Button key="done" label={STR.done} variant="primary" hotkey="d" onPress={() => done($)} />)
472
+ return frame(<Text>{STR.skipped}</Text>, q.more ? null : <Text dimColor>{STR.autoClose}</Text>, <Button key="done" label={STR.done} variant="primary" hotkey={hk('d')} onPress={() => done($)} />)
464
473
  }
465
474
  const right = r.correct === true
466
475
  return frame(
467
476
  stem,
468
- <Text bold color={right ? 'suggestion' : r.taught ? undefined : 'error'}>
477
+ <Text bold color={right ? 'suggestion' : r.taught ? BRAND : 'error'}>
469
478
  {right ? STR.correct : r.taught ? STR.taught : STR.wrong}
470
479
  </Text>,
471
480
  right || !r.correct_label ? null : <Text>{answerWas(r.correct_label)}</Text>,
@@ -474,8 +483,8 @@ export const register: Register = on => {
474
483
  r.explain ? <Text dimColor>{r.taught ? STR.taughtExplainer : STR.explainer}</Text> : null,
475
484
  q.more ? null : <Text dimColor>{STR.autoClose}</Text>,
476
485
  <Box flexDirection="row" gap={2}>
477
- {q.more ? <Button key="next" label={STR.next} hotkey="n" onPress={() => askNext($)} /> : null}
478
- <Button key="done" label={STR.done} variant="primary" hotkey="d" onPress={() => done($)} />
486
+ {q.more ? <Button key="next" label={STR.next} hotkey={hk('n')} onPress={() => askNext($)} /> : null}
487
+ <Button key="done" label={STR.done} variant="primary" hotkey={hk('d')} onPress={() => done($)} />
479
488
  </Box>,
480
489
  )
481
490
  }
@@ -485,16 +494,20 @@ export const register: Register = on => {
485
494
  const hasDraft = s.draft.other ? s.draft.text.trim().length > 0 : s.draft.picked !== null
486
495
  // The answer and its note are the Button's own inline Text, so a press anywhere on them selects.
487
496
  // Markdown marks cannot show inside a Button, so the code and bold marks are dropped from both.
497
+ // The pick shows by the card's border and fill, never a mark in the text, so picking moves nothing.
498
+ // On the desktop app the note starts where the answer does; the terminal indents it under the `1: `
499
+ // its plain Button draws.
500
+ // `wrap` asks the app to wrap a long answer rather than cut it off.
488
501
  const answer = (id: string, key: string, hotkey: string, label: string, chosen: boolean, note?: string) => (
489
502
  <Button
490
503
  key={key}
491
504
  plain
492
- hotkey={hotkey}
505
+ hotkey={hk(hotkey)}
493
506
  label={label}
494
507
  onPress={() => (locked ? undefined : id === 'other' ? chooseOther($) : pick($, id))}
495
508
  >
496
- <Text bold={chosen} color={chosen && id !== 'other' ? PICKED.text : undefined}>{`${chosen ? '✓ ' : ''}${label}`}</Text>
497
- {note ? <Text dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{`\n ${note}`}</Text> : null}
509
+ <Text wrap="wrap" bold={chosen} color={chosen && id !== 'other' ? PICKED.text : undefined}>{label}</Text>
510
+ {note ? <Text wrap="wrap" dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{`\n${app ? '' : ' '}${note}`}</Text> : null}
498
511
  </Button>
499
512
  )
500
513
  const options = q.options.map((o, i) => {
@@ -509,7 +522,7 @@ export const register: Register = on => {
509
522
 
510
523
  return frame(
511
524
  stem,
512
- <Box flexDirection="column" gap={app ? 2 : 0}>
525
+ <Box flexDirection="column" gap={app ? 2 : 0} marginTop={app ? 1 : 0}>
513
526
  {options}
514
527
  {card(
515
528
  'other',
@@ -538,14 +551,29 @@ export const register: Register = on => {
538
551
  s.step === 'awaiting' && s.message ? <Text color="warning">{s.message}</Text> : null,
539
552
  <Box flexDirection="row" gap={2}>
540
553
  {locked ? null : s.step === 'error' ? (
541
- <Button key="retry" label={STR.retry} variant="primary" hotkey="r" onPress={() => submit($, null)} />
554
+ <Button key="retry" label={STR.retry} variant="primary" hotkey={hk('r')} onPress={() => submit($, null)} />
542
555
  ) : (
543
- <Button key="submit" label={STR.submit} variant="primary" dimColor={!hasDraft} hotkey="s" onPress={() => submit($, null)} />
556
+ <Button key="submit" label={STR.submit} variant="primary" dimColor={!hasDraft} hotkey={hk('s')} onPress={() => submit($, null)} />
544
557
  )}
545
- {locked ? null : <Button key="teach" label={STR.teach} hotkey="t" onPress={() => teach($)} />}
546
- {locked ? null : <Button key="skip" label={STR.skip} dimColor hotkey="k" onPress={() => skip($)} />}
558
+ {locked ? null : <Button key="skip" label={STR.skip} dimColor hotkey={hk('k')} onPress={() => skip($)} />}
547
559
  {s.step === 'error' ? <Button key="close" label={STR.close} onPress={() => $.ui.close({ id: PANE })} /> : null}
548
560
  </Box>,
561
+ // Teach me has a row of its own, under Submit. A Button takes no fill, and a desktop draws a plain
562
+ // one without its grey chrome: the orange box is the button's body, and as a column it
563
+ // stretches the Button across its width, so all but the thin padding is pressable.
564
+ locked ? null : app ? (
565
+ <Box key="teach-box" flexDirection="column" backgroundColor={TEACH.bg} borderStyle="round" borderColor={TEACH.bg} paddingX={1} paddingY={0.5}>
566
+ <Button key="teach" plain label={STR.teach} onPress={() => teach($)}>
567
+ <Text color={TEACH.text}>{STR.teach}</Text>
568
+ </Button>
569
+ </Box>
570
+ ) : (
571
+ <Box key="teach-row" flexDirection="row">
572
+ <Button key="teach" label={STR.teach} hotkey={hk('t')} onPress={() => teach($)}>
573
+ <Text color={TEACH.text} backgroundColor={TEACH.bg}>{STR.teach}</Text>
574
+ </Button>
575
+ </Box>
576
+ ),
549
577
  )
550
578
  })
551
579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eklavya",
3
- "version": "1.71.0",
3
+ "version": "1.71.2",
4
4
  "description": "Learn while your agent works — local knowledge graph, spaced repetition, and quiz gating for agent-assisted development.",
5
5
  "license": "MIT",
6
6
  "repository": {