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 +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/hooks/CLAUDE.md +1 -1
- package/dist/plugin/hooks/panel/card.tsx +26 -7
- package/dist/plugin/hooks/panel/model.ts +39 -1
- package/dist/plugin/hooks/panel/pill.tsx +38 -0
- package/dist/plugin/hooks/panel/register.tsx +109 -24
- package/package.json +1 -1
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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({
|
|
42
|
-
|
|
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($, '
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
s.draft.other ? (
|
|
632
|
-
<Box key="other-box"
|
|
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="
|
|
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
|
-
|
|
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 :
|
|
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