eklavya 1.87.0 → 1.89.0

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 (automatic questions wait for a code change, not a `git pull`; see `quiz.only_on_changes`) | `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; its header stacks the mark above the topic in a pane under 60 cells wide; on the desktop app each option's note is its own line under the option, and a click on the option picks it; `/eklavya-panel-demo` opens a mock question the panel answers itself, calling none of these tools |
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; on the desktop app each option is a numbered card drawn as one pressable region (a click anywhere on it picks it), while VS Code and the terminal keep native buttons; `/eklavya-panel-demo` opens a mock question the panel answers itself, calling none of these tools |
56
56
  | Settings | `get_config`, `set_config` |
57
57
  | Find and read memory (`memory_status` includes a `health` verdict with reasons) | `memory_search`, `memory_get`, `memory_timeline`, `memory_file_history`, `memory_status` |
58
58
  | Maintain memory | `memory_write`, `memory_correct`, `memory_delete`, `memory_collections` |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eklavya",
3
3
  "displayName": "Eklavya",
4
- "version": "1.87.0",
4
+ "version": "1.89.0",
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"
@@ -56,7 +56,7 @@ returns `{disabled: true}` and the mod opens and draws nothing. `presentation` i
56
56
  the Stop sweep and the delegation lines (`delegation-lib.ts`) read it, and an
57
57
  open panel question silences the checkpoint and the Stop sweep. Read
58
58
  `docs/verified-schemas.md` (mods section) before changing anything it records.
59
- `/eklavya-panel-demo` (`DEMO_COMMAND`) is the maintainer's shortcut for trying the pane: it seeds `PanelState.demo` with a mock question and never calls the server, so it works with `quiz.panel` off. While `demo` is set the heartbeat (`beat`) does not sync, `submit` grades locally with `demoResult` (no `panel_answer`, grader or explainer) and Next restarts the mock; `/eklavya-panel` and a real `present_question` sync replace it. On a non-terminal surface it also offers the Answer cards switch: `button` is the production layout, `client` draws each option as a `Client` region from `card.tsx` that posts `{pick}` to the `ui.message` hook, which accepts only an id of the open question. The switch is demo-only until the whole-card press is observed on Desktop; then make it the default and say so in `docs/verified-schemas.md`.
59
+ `/eklavya-panel-demo` (`DEMO_COMMAND`) is the maintainer's shortcut for trying the pane: it seeds `PanelState.demo` with a mock question and never calls the server, so it works with `quiz.panel` off. While `demo` is set the heartbeat (`beat`) does not sync, `submit` grades locally with `demoResult` (no `panel_answer`, grader or explainer) and Next restarts the mock; `/eklavya-panel` and a real `present_question` sync replace it. Only the desktop draws `Client` regions (VS Code has `Svg` but no `Client`, and a tree holding an element its surface lacks is replaced whole): there every answer is a numbered whole-card region from `card.tsx`, Teach me is a compact `pill.tsx` region, the Other header is a `bare` card over an `Input` of three rows, and the header mark is `MASCOT_FACE` as an `Svg`; they post `{pick}` or `{press}` to the `ui.message` hook, which accepts only an id of the open question, `other`, or `teach`. VS Code and the terminal keep native `Button`s (VS Code numbers the labels itself, the terminal's plain Button draws `1:`). The demo's Answer cards switch (desktop only) compares the whole-card default with the native buttons. Under the Teach me button the demo also draws an experiment box (`experiment` in `register.tsx`): `Image` with `DEMO_PNG` on the terminal, two `Svg` (`demoSvg`, `demoSvgWithPng`) on every other surface, never both, because a tree holding an element its surface lacks is replaced whole by the host's own pane.
60
60
  Run `scripts/test-panel-mod.sh`: it stages the mod as its own plugin, validates
61
61
  it, runs `claude plugin test`, then validates the distributed plugin.
62
62
 
@@ -2,19 +2,25 @@ import type { ClientModule } from 'claude-code'
2
2
 
3
3
  /**
4
4
  * One answer card drawn as a `Client` region, so a press anywhere on it (border,
5
- * padding, title or note) picks it. A `Button` is only as large as its label, and a
5
+ * padding, number, title or note) picks it. A `Button` is only as large as its label, and a
6
6
  * `Box` takes no press, so this is the one way to make the whole card the target.
7
7
  *
8
8
  * It gets no mods API: it holds only its hover and posts `{ pick: id }` to the hooks
9
9
  * module, whose `ui.message` hook validates the id and records the pick. The colours
10
10
  * come in as props, so the module imports nothing.
11
+ *
12
+ * `bare` draws only the pressable header (padding and text, no border or fill): the Other
13
+ * card's frame and its text box belong to the hooks module, and this is the row that selects it.
11
14
  */
12
15
  export type CardProps = {
13
16
  id: string
14
17
  label: string
15
18
  note: string
19
+ /** The answer's number, drawn before the text; absent for Other. */
20
+ n?: number
16
21
  chosen: boolean
17
22
  locked: boolean
23
+ bare?: boolean
18
24
  brand: string
19
25
  fill: string
20
26
  text: string
@@ -30,16 +36,29 @@ const Card: ClientModule<CardProps, { hover: boolean }> = (p, s) => {
30
36
  else if (e.type === 'leave' && hover) s.setState({ hover: false })
31
37
  else if (e.type === 'up' && !p.locked && (e.button === undefined || e.button === 'left')) s.post({ pick: p.id })
32
38
  })
33
- return Box({
39
+ const words = Box({
34
40
  flexDirection: 'column',
41
+ flexShrink: 1,
42
+ children: [
43
+ Text({ wrap: 'wrap', bold: p.chosen && !p.bare, color: p.chosen && !p.bare ? p.text : undefined, children: [p.label] }),
44
+ ...(p.note ? [Text({ wrap: 'wrap', dimColor: !p.chosen, color: p.chosen ? p.noteText : undefined, children: [p.note] })] : []),
45
+ ],
46
+ })
47
+ return Box({
48
+ flexDirection: p.n === undefined ? 'column' : 'row',
49
+ gap: 2,
35
50
  paddingX: 2,
36
51
  paddingY: 1,
37
- borderStyle: p.chosen ? 'bold' : 'single',
38
- borderColor: p.chosen || (hover && !p.locked) ? p.brand : 'inactive',
39
- backgroundColor: p.chosen ? p.fill : undefined,
52
+ ...(p.bare
53
+ ? {}
54
+ : {
55
+ borderStyle: p.chosen ? 'bold' : 'single',
56
+ borderColor: p.chosen || (hover && !p.locked) ? p.brand : 'inactive',
57
+ backgroundColor: p.chosen ? p.fill : undefined,
58
+ }),
40
59
  children: [
41
- Text({ wrap: 'wrap', bold: p.chosen, color: p.chosen ? p.text : undefined, children: [p.label] }),
42
- Text({ wrap: 'wrap', dimColor: !p.chosen, color: p.chosen ? p.noteText : undefined, children: [p.note] }),
60
+ ...(p.n === undefined ? [] : [Text({ bold: true, dimColor: !p.chosen, color: p.chosen ? p.text : undefined, children: [String(p.n)] })]),
61
+ words,
43
62
  ],
44
63
  })
45
64
  }
@@ -23,7 +23,7 @@ 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
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' }
26
+ export const TEACH = { bg: '#D97757', text: '#1F1E1D', hover: '#E48866' }
27
27
 
28
28
  /**
29
29
  * The logo: the Claude mascot (orange) holding an Eklavya bow and arrow (white), drawn on a
@@ -171,6 +171,13 @@ export const STR = {
171
171
  demoStyleClient: 'Whole card',
172
172
  demoExplanation: 'A rename inside one folder is atomic: the poller sees the finished file or none, never half of it. (This is a mock, so it was not recorded.)',
173
173
  demoTyped: 'Typed answers are not graded in the demo. (Nothing was sent or recorded.)',
174
+ mascotAlt: 'The Eklavya mascot',
175
+ experimentBanner: 'EXPERIMENT · picture test. Nothing here is part of a real question.',
176
+ experimentTerminal: 'Image: a PNG, drawn as a picture in kitty, Ghostty and iTerm2 (fullscreen); other terminals show the dim text instead. SVG is not drawn in the terminal.',
177
+ experimentImageAlt: 'The Eklavya mark: the Claude mascot holding a bow and arrow',
178
+ experimentNone: 'This surface draws neither an SVG nor an image, so there is nothing to test here.',
179
+ experimentSvg: 'SVG: the mark drawn as vector rectangles.',
180
+ experimentSvgPng: 'Image: the same mark as a PNG inside an SVG (Desktop has no Image element).',
174
181
  } as const
175
182
 
176
183
  /** The explainer agent the panel starts itself, so no prompt lands in the transcript. */
@@ -241,6 +248,37 @@ export function demoResult(kind: 'choice' | 'text' | 'skip' | 'dont_know', optio
241
248
  return { phase: 'answered', attempt_id: 0, correct, correct_label: right, explanation: kind === 'text' ? STR.demoTyped : STR.demoExplanation }
242
249
  }
243
250
 
251
+ /**
252
+ * The mascot's neutral face, the header mark on every surface that draws `Svg`. A copy of
253
+ * `web/public/brand/mascot/expressions/neutral-face.svg` (the mod has no filesystem); a test keeps the two equal,
254
+ * so regenerate with `npm run mascot:generate` and paste the file. The terminal keeps the block-character logo.
255
+ */
256
+ export const MASCOT_FACE =
257
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"30 12 126 128\" width=\"200\" height=\"200\" class=\"ek-mascot\" data-expression=\"neutral\" data-variant=\"face\" role=\"img\"><style>:root {/* ---------- MASCOT IDENTITY ----------\n Archer Cloak artwork keeps its approved identity across both grounds.\n UI chrome continues to use the surface, text and accent roles below. */\n --mascot-coral: #ef9875;\n --mascot-coral-dark: #c96d51;\n --mascot-highlight: #ffc8a6;\n --mascot-cloak: #0E6E66;\n --mascot-cloak-dark: #07403C;\n --mascot-thread: #C4EDE4;\n --mascot-ink: #302c2a;\n --mascot-metal: #5b7c73;\n --mascot-wood: #bc9569;\n --mascot-wood-dark: #74583f;\n --mascot-string: #EAE7E1;\n --mascot-shadow: #26262a;\n /* ---------- END MASCOT IDENTITY ---------- */}/* Identity colours and UI surfaces use Eklavya's semantic tokens. */\neklavya-mascot { display:inline-flex; flex-shrink:0; vertical-align:middle; line-height:0; }\n.ek-mascot { display:block; overflow:visible; flex-shrink:0; }\n.ek-mascot[data-variant=\"face\"] { overflow:hidden; }\n.ek-mascot__face path:not([fill]) { fill:none; }\n.ek-companion { display:flex;align-items:center;gap:12px;color:var(--dim);font:13px/1.5 var(--font-body); }\n.ek-companion--hero { margin-bottom:16px; }\n.ek-companion--hero b { display:block;color:var(--ink);font-weight:600; }\n.ek-mascot-message { display:flex;align-items:center;gap:16px;padding:16px 0; }\n.ek-mascot-message p {margin:0}\n/* Secondary loader: draws and releases an arrow over 2.8 s. */\n.ek-mascot--draw .ek-mascot__character { animation:ek-breathe 2.8s ease-in-out infinite; transform-origin:95px 185px; }\n.ek-mascot--draw .ek-mascot__arrow { animation:ek-release 2.8s cubic-bezier(.3,0,.3,1) infinite; }\n.ek-mascot--draw .ek-mascot__string { animation:ek-draw 2.8s ease-in-out infinite; transform-origin:157px 125px; }\n.ek-mascot--draw .ek-mascot__ripple { animation:ek-hit 2.8s ease-out infinite; transform-origin:196px 128px; opacity:0; }\n@keyframes ek-breathe { 0%,100% {transform:translateY(0)} 35%,55% {transform:translateY(-3px) rotate(-1deg)} }\n@keyframes ek-release { 0%,12% {transform:translateX(0);opacity:1} 40%,52% {transform:translateX(-10px);opacity:1} 68% {transform:translateX(22px);opacity:0} 69%,85% {transform:translateX(0);opacity:0} 100% {transform:translateX(0);opacity:1} }\n@keyframes ek-draw { 0%,100% {transform:scaleX(1)} 40%,52% {transform:scaleX(1.11)} 60% {transform:scaleX(.08)} 68% {transform:scaleX(1)} }\n@keyframes ek-hit { 0%,60%,100% {opacity:0;transform:scale(.4)} 68% {opacity:.8;transform:scale(.7)} 88% {opacity:0;transform:scale(1.7)} }\n/* Loader: walking toward the viewer, a 1.2 s stride (two steps). Ground rungs recede on an ease-out, as perspective slows them near the horizon. */\n.ek-mascot--loading .ek-mascot__walker { animation:ek-sway 1.2s ease-in-out infinite; transform-origin:95px 185px; }\n.ek-mascot--loading .ek-mascot__torso { animation:ek-bob .6s ease-in-out infinite; }\n.ek-mascot--loading .ek-mascot__leg, .ek-mascot--loading .ek-mascot__hand { animation:ek-step 1.2s ease-in-out infinite; }\n.ek-mascot--loading .ek-mascot__leg--r, .ek-mascot--loading .ek-mascot__hand--arrow { animation-delay:-.6s; }\n.ek-mascot--loading .ek-mascot__shadow { animation:ek-shadow .6s ease-in-out infinite; transform-origin:95px 186px; }\n.ek-mascot--loading .ek-mascot__rung { animation:ek-recede 1.2s cubic-bezier(.15,.6,.4,1) infinite; transform-origin:95px 162px; }\n.ek-mascot--loading .ek-mascot__rung:nth-child(3) { animation-delay:-.3s; }\n.ek-mascot--loading .ek-mascot__rung:nth-child(4) { animation-delay:-.6s; }\n.ek-mascot--loading .ek-mascot__rung:nth-child(5) { animation-delay:-.9s; }\n@keyframes ek-sway { 0%,100% {transform:rotate(-1.5deg)} 50% {transform:rotate(1.5deg)} }\n@keyframes ek-bob { 0%,100% {transform:translateY(1px)} 50% {transform:translateY(-3px)} }\n@keyframes ek-step { 0%,50%,100% {transform:translateY(0)} 25% {transform:translateY(-6px)} }\n@keyframes ek-shadow { 0%,100% {transform:scaleX(1)} 50% {transform:scaleX(.92)} }\n@keyframes ek-recede { 0% {transform:translateY(56px) scaleX(1);opacity:0} 10% {opacity:1} 85% {opacity:1} 100% {transform:translateY(0) scaleX(.4);opacity:0} }\n@media (prefers-reduced-motion:reduce) { .ek-mascot * {animation:none !important;transition:none !important} }\n\n.ek-companion--rail { padding:12px 20px; text-decoration:none; }\n.ek-mascot-message > :last-child { min-width:0; overflow-wrap:break-word; }\n.ek-mascot-status { display:inline-flex; align-items:center; gap:6px; }\n</style><title>Eklavya, calm and attentive</title><path d=\"M48 109q44-19 88 0l-8 30-33-15-31 15Z\" fill=\"var(--mascot-cloak)\"/><g class=\"ek-mascot__head\">\n <path d=\"M46 43h18V29h19V18h23v12h20v14h17v19h8v29h-8v16q0 12-16 15H62q-18-3-18-17V91h-9V63h11Z\" fill=\"var(--mascot-coral)\" stroke=\"var(--mascot-coral-dark)\" stroke-width=\"2\" stroke-linejoin=\"round\"/>\n <path d=\"M48 45h18V32h18V21h19\" fill=\"none\" stroke=\"var(--mascot-highlight)\" stroke-width=\"3\" stroke-linejoin=\"round\"/>\n <g class=\"ek-mascot__face\" fill=\"var(--mascot-ink)\" stroke=\"var(--mascot-ink)\" stroke-width=\"3.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"64\" y=\"64\" width=\"10\" height=\"19\" rx=\"5\"/><rect x=\"110\" y=\"64\" width=\"10\" height=\"19\" rx=\"5\"/><path d=\"M84 89q8 6 16 0\"/></g>\n </g><circle cx=\"95\" cy=\"126\" r=\"6\" fill=\"var(--mascot-metal)\"/></svg>"
258
+
259
+ /** The logo as a PNG (208 by 96, one grid cell is 8 pixels), for the demo's picture test: the mod has no filesystem. */
260
+ export const DEMO_PNG =
261
+ 'iVBORw0KGgoAAAANSUhEUgAAANAAAABgCAYAAABsbvwBAAABcElEQVR42u3dwQ2DMBBFQXdJKdRKNaYDDqDFu+t50j8mUpDnZCUZQ3pofswTFEAASQBJAEkASQABJAEkASTlBJL9/SWAJIAAEkAASQBJAEkAASSAAJJyHnDABBBAAgggCSAJIIAEEEACCCAJoLpd5zFt3aof4O2BOcQAAQQQQAABBBBAABlAABlAAAEEEEAAAQRQY0Ddga5+ftUPaHtgAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAAB5CLUXk7BfyDmkAEkgAwggAwggAwggAwgAWQEAGQAJQbkHskX6lykuogFCCCADCCADCCAAAIIIIAAAgggAwggAwigCGDRr9/98/lhRYAAAggggAACCCCAAHLAAAIIIIAAAggggAACCCCA1LPVBxQQAQSQAAJIAkgCCCABBJAAAkgCSKp5gAERQABJAEkASQABJIAAkgCSAJJqAgNEAkgCSAJIAgggCSAJIAkgSZIkSZIkSZIk6dduN5j9QcRn8BgAAAAASUVORK5CYII='
262
+
263
+ /** The logo as SVG, one rect per run of a colour, so the demo can test a vector picture without an asset file. */
264
+ export function demoSvg(): string {
265
+ const rects = boxRows()
266
+ .flatMap((runs, y) => {
267
+ let x = 0
268
+ return runs.map(run => {
269
+ const at = x
270
+ x += run.width
271
+ return run.color ? `<rect x="${at}" y="${y}" width="${run.width}" height="1" fill="${run.color}"/>` : ''
272
+ })
273
+ })
274
+ .join('')
275
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${LOGO_GRID[0]!.length} ${LOGO_GRID.length}" shape-rendering="crispEdges">${rects}</svg>`
276
+ }
277
+
278
+ /** The same picture as a PNG inside an SVG: Desktop has no Image element, so this is how a raster is tried there. */
279
+ export const demoSvgWithPng = (): string =>
280
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 208 96"><image href="data:image/png;base64,${DEMO_PNG}" width="208" height="96"/></svg>`
281
+
244
282
  /** The tester's line of what was just clicked, and how the click arrived. */
245
283
  export const demoLast = (what: string): string => `Last click: ${what}`
246
284
 
@@ -0,0 +1,38 @@
1
+ import type { ClientModule } from 'claude-code'
2
+
3
+ /**
4
+ * A compact filled button drawn as a `Client` region, so a press anywhere on it (fill, padding or
5
+ * text) counts. A `Button` takes no fill, and a `Box` takes no press, so a coloured button whose
6
+ * whole body is pressable has to be a region of its own. It sizes to its text and posts
7
+ * `{ press: id }` to the hooks module, which validates it. Colours come in as props.
8
+ */
9
+ export type PillProps = {
10
+ id: string
11
+ label: string
12
+ locked: boolean
13
+ fill: string
14
+ hoverFill: string
15
+ text: string
16
+ }
17
+
18
+ const Pill: ClientModule<PillProps, { hover: boolean }> = (p, s) => {
19
+ const { Box, Text } = s.elements
20
+ const hover = s.state?.hover ?? false
21
+ s.onPointer(e => {
22
+ if (e.type === 'enter' && !hover) s.setState({ hover: true })
23
+ else if (e.type === 'leave' && hover) s.setState({ hover: false })
24
+ else if (e.type === 'up' && !p.locked && (e.button === undefined || e.button === 'left')) s.post({ press: p.id })
25
+ })
26
+ const fill = hover && !p.locked ? p.hoverFill : p.fill
27
+ return Box({
28
+ flexDirection: 'row',
29
+ paddingX: 2,
30
+ paddingY: 0.5,
31
+ borderStyle: 'round',
32
+ borderColor: fill,
33
+ backgroundColor: fill,
34
+ children: [Text({ color: p.text, children: [p.label] })],
35
+ })
36
+ }
37
+
38
+ export default Pill
@@ -3,6 +3,8 @@ import type { EngineInterface, Register } from 'claude-code'
3
3
 
4
4
  import {
5
5
  BRAND,
6
+ DEMO_PNG,
7
+ MASCOT_FACE,
6
8
  PICKED,
7
9
  TEACH,
8
10
  EMPTY,
@@ -17,6 +19,8 @@ import {
17
19
  answerWas,
18
20
  demoLast,
19
21
  demoResult,
22
+ demoSvg,
23
+ demoSvgWithPng,
20
24
  explainerBrief,
21
25
  codify,
22
26
  gradingRequest,
@@ -34,6 +38,7 @@ import {
34
38
  } from './model'
35
39
  import type { PanelPending, PanelQuestion, PanelState } from './types'
36
40
  import type { CardProps } from './card'
41
+ import type { PillProps } from './pill'
37
42
 
38
43
  /**
39
44
  * The Eklavya quiz panel: a pane that shows the question the tutor model handed
@@ -403,20 +408,24 @@ export const register: Register = on => {
403
408
  // The mock question: opens at once, answered on this machine, so a card or a click can be tried with no real question waiting.
404
409
  on('command.run', { command: 'eklavya-panel-demo' }, async $ => {
405
410
  try {
406
- await startDemo($, 'button')
411
+ await startDemo($, 'client')
407
412
  return { text: STR.demoOpened }
408
413
  } catch {
409
414
  return { text: STR.demoFailed }
410
415
  }
411
416
  })
412
417
 
413
- // A press on a whole-card `Client` (card.tsx) arrives here as data, never as a fact: only an id of the open question counts.
418
+ // A press on a `Client` region (card.tsx, pill.tsx) arrives here as data, never as a fact: only an id of the open question, Other, or Teach me counts.
414
419
  on('ui.message', { component: 'Pane' }, async ($, e, next) => {
415
420
  if (e.requestId === PANE) {
416
421
  try {
417
- const id = (e.data as { pick?: unknown } | null)?.pick
422
+ const started = Date.now()
423
+ const data = e.data as { pick?: unknown; press?: unknown } | null
424
+ const id = data?.pick
418
425
  const s: PanelState = (await $.state.get(REF)).value ?? EMPTY
419
- if (typeof id === 'string' && s.question?.options.some(o => o.id === id)) await pick($, id, 'whole card')
426
+ if (id === 'other') await chooseOther($)
427
+ else if (typeof id === 'string' && s.question?.options.some(o => o.id === id)) await pick($, id, 'whole card', started)
428
+ else if (data?.press === 'teach' && s.question && (s.step === 'awaiting' || s.step === 'error')) await teach($)
420
429
  } catch {
421
430
  /* Fail open: the card stays as it was. */
422
431
  }
@@ -438,13 +447,18 @@ export const register: Register = on => {
438
447
  on('ui.render', { component: 'Pane', requestId: 'eklavya-quiz' }, async ($, e, next) => {
439
448
  // Only our pane: every other pane, and everything else, is the host's.
440
449
  if (e.requestId !== PANE) return next(e)
441
- const { Box, Text, Button, Input, Markdown, Client } = $.ui.resolve(e) as any
450
+ const { Box, Text, Button, Input, Markdown, Client, Svg, Image } = $.ui.resolve(e) as any
442
451
  const s: PanelState = await read($, quiz)
443
452
  const q = s.question
444
453
 
445
454
  // Any surface but the terminal is the desktop app's: it sets the question as a heading, spaces the
446
455
  // answers apart and draws the mark in boxes. The terminal keeps its text layout.
447
456
  const app = e.surface !== 'terminal'
457
+ // Only the desktop app draws a `Client` (the whole-card answers, the Teach me pill, the Other row). VS Code has `Svg` but no `Client`:
458
+ // a tree holding an element its surface lacks is replaced whole, so VS Code keeps the native buttons.
459
+ const desktop = e.surface === 'desktop'
460
+ // `Svg` is drawn by the desktop app and VS Code only (the elements table lists no other surface), so any other surface keeps the grid mark.
461
+ const vector = desktop || e.surface === 'vscode'
448
462
  // The mark is drawn in text, as Claude Code draws its own, so it shows in every terminal. The
449
463
  // desktop app sets text in a proportional font with its own line height, so glyph cells do not
450
464
  // tile there: it gets the same grid as filled boxes, one per run of a colour. A cell is one unit
@@ -463,6 +477,11 @@ export const register: Register = on => {
463
477
  </Box>
464
478
  ))}
465
479
  </Box>
480
+ ) : vector ? (
481
+ // The mascot's own neutral face, as the vector artwork the brand ships (the block grid is for the terminal's cells).
482
+ <Box flexDirection="column" flexShrink={0}>
483
+ <Svg alt={STR.mascotAlt} width={72} height={73} source={MASCOT_FACE} />
484
+ </Box>
466
485
  ) : (
467
486
  <Box flexDirection="column" flexShrink={0}>
468
487
  {boxRows().map((runs, y) => (
@@ -524,7 +543,7 @@ export const register: Register = on => {
524
543
  <Box key="demo-bar" flexDirection="column" gap={app ? 1 : 0}>
525
544
  <Text color="warning">{STR.demoBanner}</Text>
526
545
  <Text dimColor>{s.demo.last}</Text>
527
- {app ? (
546
+ {desktop ? (
528
547
  <Box flexDirection="row" gap={2} alignItems="center">
529
548
  <Text dimColor>{STR.demoStyleLabel}</Text>
530
549
  <Button key="style-button" label={STR.demoStyleButton} variant={s.demo.style === 'button' ? 'primary' : 'secondary'} onPress={() => setStyle($, 'button')} />
@@ -600,18 +619,21 @@ export const register: Register = on => {
600
619
  {note && !app ? <Text wrap="wrap" dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{`\n ${note}`}</Text> : null}
601
620
  </Button>
602
621
  )
603
- const wholeCard = app && s.demo?.style === 'client'
622
+ // The desktop app draws each answer as one pressable region, so a click on its padding or note picks too (card.tsx);
623
+ // the demo can switch back to the native buttons to compare.
624
+ const wholeCard = desktop && (s.demo ? s.demo.style === 'client' : true)
625
+ const cardProps = (id: string, over: Partial<CardProps>): CardProps => ({ id, label: '', note: '', chosen: false, locked, brand: BRAND, fill: PICKED.bg, text: PICKED.text, noteText: PICKED.note, ...over })
604
626
  const options = q.options.map((o, i) => {
605
627
  const chosen = !s.draft.other && s.draft.picked === o.id
606
- // The whole card is one pressable region, so a click on its padding or note picks too (card.tsx).
607
628
  if (wholeCard) {
608
- const props: CardProps = { id: o.id, label: plainLabel(o.label), note: o.note ? plainLabel(o.note) : '', chosen, locked, brand: BRAND, fill: PICKED.bg, text: PICKED.text, noteText: PICKED.note }
629
+ const props = cardProps(o.id, { label: plainLabel(o.label), note: o.note ? plainLabel(o.note) : '', n: i + 1, chosen })
609
630
  return <Client key={`card-${o.id}`} module="./card.tsx" width="100%" props={props} />
610
631
  }
611
632
  return card(
612
633
  o.id,
613
634
  chosen,
614
- answer(o.id, `opt-${o.id}`, String(i + 1), plainLabel(o.label), chosen, o.note ? plainLabel(o.note) : undefined),
635
+ // The terminal's plain Button draws its own `1:`; the other surfaces number the label themselves.
636
+ answer(o.id, `opt-${o.id}`, String(i + 1), app ? `${i + 1} ${plainLabel(o.label)}` : plainLabel(o.label), chosen, o.note ? plainLabel(o.note) : undefined),
615
637
  // The desktop app draws only a Button's label, not its inline children, so there the note sits under the button.
616
638
  app && o.note
617
639
  ? [<Text key={`note-${o.id}`} wrap="wrap" dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{plainLabel(o.note)}</Text>]
@@ -619,30 +641,81 @@ export const register: Register = on => {
619
641
  )
620
642
  })
621
643
 
644
+ // The demo's picture test, under Teach me. The terminal has Image and no Svg; every other surface has Svg and no Image
645
+ // (a tree with an element its surface lacks is replaced whole by the host's own pane, so each surface gets only its own).
646
+ const experiment = s.demo ? (
647
+ <Box key="experiment" flexDirection="column" gap={1} borderStyle="dashed" borderColor="warning" paddingX={1} paddingY={app ? 1 : 0}>
648
+ <Text color="warning" bold>
649
+ {STR.experimentBanner}
650
+ </Text>
651
+ {vector ? (
652
+ <Box flexDirection="column" gap={1}>
653
+ <Text dimColor>{STR.experimentSvg}</Text>
654
+ <Svg alt={STR.experimentImageAlt} width={208} height={96} source={demoSvg()} />
655
+ <Text dimColor>{STR.experimentSvgPng}</Text>
656
+ <Svg alt={STR.experimentImageAlt} width={208} height={96} source={demoSvgWithPng()} />
657
+ </Box>
658
+ ) : app ? (
659
+ <Text dimColor wrap="wrap">
660
+ {STR.experimentNone}
661
+ </Text>
662
+ ) : (
663
+ <Box flexDirection="column">
664
+ <Text dimColor wrap="wrap">
665
+ {STR.experimentTerminal}
666
+ </Text>
667
+ <Image key="experiment-image" source={{ png: DEMO_PNG }} columns={26} rows={6} alt={STR.experimentImageAlt} />
668
+ </Box>
669
+ )}
670
+ </Box>
671
+ ) : null
672
+
622
673
  return frame(
623
674
  stem,
624
675
  <Box flexDirection="column" gap={app ? 2 : 0} marginTop={app ? 1 : 0}>
625
676
  {options}
626
- {card(
627
- 'other',
628
- s.draft.other,
629
- answer('other', 'opt-other', 'o', STR.other, s.draft.other),
630
- [
631
- s.draft.other ? (
632
- <Box key="other-box" paddingLeft={3}>
677
+ {wholeCard ? (
678
+ // The header row is the pressable region; the frame and the text box are ours. Three lines tall, saved on every keystroke,
679
+ // so the button the field draws beside itself has nothing to do: its label is left empty to try to hide it.
680
+ <Box key="card-other" flexDirection="column" borderStyle={s.draft.other ? 'bold' : 'single'} borderColor={s.draft.other ? BRAND : 'inactive'}>
681
+ <Client key="other-head" module="./card.tsx" width="100%" props={cardProps('other', { label: STR.other, chosen: s.draft.other, bare: true })} />
682
+ {s.draft.other ? (
683
+ <Box key="other-box" paddingX={2} paddingBottom={1}>
633
684
  <Input
634
685
  key="other-text"
635
686
  autoFocus
687
+ rows={3}
636
688
  placeholder={STR.otherPlaceholder}
637
689
  value={s.draft.text}
638
- submitLabel="keep"
690
+ submitLabel=""
639
691
  onInput={(value: string) => (locked ? undefined : typeOther($, value))}
640
692
  onSubmit={() => (locked ? undefined : keepOther($))}
641
693
  />
642
694
  </Box>
643
- ) : null,
644
- ],
645
- s.draft.other,
695
+ ) : null}
696
+ </Box>
697
+ ) : (
698
+ card(
699
+ 'other',
700
+ s.draft.other,
701
+ answer('other', 'opt-other', 'o', STR.other, s.draft.other),
702
+ [
703
+ s.draft.other ? (
704
+ <Box key="other-box" paddingLeft={3}>
705
+ <Input
706
+ key="other-text"
707
+ autoFocus
708
+ placeholder={STR.otherPlaceholder}
709
+ value={s.draft.text}
710
+ submitLabel="keep"
711
+ onInput={(value: string) => (locked ? undefined : typeOther($, value))}
712
+ onSubmit={() => (locked ? undefined : keepOther($))}
713
+ />
714
+ </Box>
715
+ ) : null,
716
+ ],
717
+ s.draft.other,
718
+ )
646
719
  )}
647
720
  </Box>,
648
721
  locked ? <Text dimColor>{STR.grading}</Text> : null,
@@ -660,7 +733,16 @@ export const register: Register = on => {
660
733
  // Teach me has a row of its own, under Submit. A Button takes no fill, and a desktop draws a plain
661
734
  // one without its grey chrome: the orange box is the button's body, and as a column it
662
735
  // stretches the Button across its width, so all but the thin padding is pressable.
663
- locked ? null : app ? (
736
+ locked ? null : desktop ? (
737
+ // A compact pill whose whole body is pressable (pill.tsx), sized to its text instead of the pane's width.
738
+ <Box key="teach-row" flexDirection="row">
739
+ <Client
740
+ key="teach-client"
741
+ module="./pill.tsx"
742
+ props={{ id: 'teach', label: STR.teach, locked, fill: TEACH.bg, hoverFill: TEACH.hover, text: TEACH.text } satisfies PillProps}
743
+ />
744
+ </Box>
745
+ ) : app ? (
664
746
  <Box key="teach-box" flexDirection="column" backgroundColor={TEACH.bg} borderStyle="round" borderColor={TEACH.bg} paddingX={1} paddingY={0.5}>
665
747
  <Button key="teach" plain label={STR.teach} onPress={() => teach($)}>
666
748
  <Text color={TEACH.text}>{STR.teach}</Text>
@@ -673,6 +755,7 @@ export const register: Register = on => {
673
755
  </Button>
674
756
  </Box>
675
757
  ),
758
+ experiment,
676
759
  )
677
760
  })
678
761
  }
@@ -683,12 +766,14 @@ async function setStyle($: EngineInterface, style: 'button' | 'client'): Promise
683
766
  }
684
767
 
685
768
  /** Selecting an option records a draft and nothing else: it never submits, grades or skips. */
686
- async function pick($: EngineInterface, id: string, via = 'button'): Promise<void> {
769
+ async function pick($: EngineInterface, id: string, via = 'button', since?: number): Promise<void> {
687
770
  await update($, quiz, (s: PanelState) =>
688
771
  s.step === 'awaiting' || s.step === 'error'
689
- ? { ...s, draft: { ...s.draft, picked: id, other: false }, message: null, ...(s.demo ? { demo: { ...s.demo, last: demoLast(`${id} picked (${via})`) } } : {}) }
772
+ ? { ...s, draft: { ...s.draft, picked: id, other: false }, message: null, ...(s.demo ? { demo: { ...s.demo, last: demoLast(`${id} picked (${via}${since === undefined ? '' : `, ${Date.now() - since} ms in the mod`})`) } } : {}) }
690
773
  : s,
691
774
  )
775
+ // A whole card has no Button to focus, so asking the host to find `opt-…` would only make the pick wait.
776
+ if (via !== 'button') return
692
777
  try {
693
778
  // The host's focus mark follows the pick, so the highlighted answer is the chosen one even after a hotkey.
694
779
  await $.ui.focus({ requestId: PANE, key: `opt-${id}` })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eklavya",
3
- "version": "1.87.0",
3
+ "version": "1.89.0",
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": {