eklavya 1.88.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 +11 -1
- package/dist/plugin/hooks/panel/pill.tsx +38 -0
- package/dist/plugin/hooks/panel/register.tsx +75 -22
- 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,9 +171,11 @@ 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',
|
|
174
175
|
experimentBanner: 'EXPERIMENT · picture test. Nothing here is part of a real question.',
|
|
175
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.',
|
|
176
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.',
|
|
177
179
|
experimentSvg: 'SVG: the mark drawn as vector rectangles.',
|
|
178
180
|
experimentSvgPng: 'Image: the same mark as a PNG inside an SVG (Desktop has no Image element).',
|
|
179
181
|
} as const
|
|
@@ -246,6 +248,14 @@ export function demoResult(kind: 'choice' | 'text' | 'skip' | 'dont_know', optio
|
|
|
246
248
|
return { phase: 'answered', attempt_id: 0, correct, correct_label: right, explanation: kind === 'text' ? STR.demoTyped : STR.demoExplanation }
|
|
247
249
|
}
|
|
248
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
|
+
|
|
249
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. */
|
|
250
260
|
export const DEMO_PNG =
|
|
251
261
|
'iVBORw0KGgoAAAANSUhEUgAAANAAAABgCAYAAABsbvwBAAABcElEQVR42u3dwQ2DMBBFQXdJKdRKNaYDDqDFu+t50j8mUpDnZCUZQ3pofswTFEAASQBJAEkASQABJAEkASTlBJL9/SWAJIAAEkAASQBJAEkAASSAAJJyHnDABBBAAgggCSAJIIAEEEACCCAJoLpd5zFt3aof4O2BOcQAAQQQQAABBBBAABlAABlAAAEEEEAAAQRQY0Ddga5+ftUPaHtgAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAAB5CLUXk7BfyDmkAEkgAwggAwggAwggAwgAWQEAGQAJQbkHskX6lykuogFCCCADCCADCCAAAIIIIAAAgggAwggAwigCGDRr9/98/lhRYAAAggggAACCCCAAHLAAAIIIIAAAggggAACCCCA1LPVBxQQAQSQAAJIAkgCCCABBJAAAkgCSKp5gAERQABJAEkASQABJIAAkgCSAJJqAgNEAkgCSAJIAgggCSAJIAkgSZIkSZIkSZIk6dduN5j9QcRn8BgAAAAASUVORK5CYII='
|
|
@@ -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
|
|
@@ -4,6 +4,7 @@ import type { EngineInterface, Register } from 'claude-code'
|
|
|
4
4
|
import {
|
|
5
5
|
BRAND,
|
|
6
6
|
DEMO_PNG,
|
|
7
|
+
MASCOT_FACE,
|
|
7
8
|
PICKED,
|
|
8
9
|
TEACH,
|
|
9
10
|
EMPTY,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
} from './model'
|
|
38
39
|
import type { PanelPending, PanelQuestion, PanelState } from './types'
|
|
39
40
|
import type { CardProps } from './card'
|
|
41
|
+
import type { PillProps } from './pill'
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* The Eklavya quiz panel: a pane that shows the question the tutor model handed
|
|
@@ -406,21 +408,24 @@ export const register: Register = on => {
|
|
|
406
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.
|
|
407
409
|
on('command.run', { command: 'eklavya-panel-demo' }, async $ => {
|
|
408
410
|
try {
|
|
409
|
-
await startDemo($, '
|
|
411
|
+
await startDemo($, 'client')
|
|
410
412
|
return { text: STR.demoOpened }
|
|
411
413
|
} catch {
|
|
412
414
|
return { text: STR.demoFailed }
|
|
413
415
|
}
|
|
414
416
|
})
|
|
415
417
|
|
|
416
|
-
// 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.
|
|
417
419
|
on('ui.message', { component: 'Pane' }, async ($, e, next) => {
|
|
418
420
|
if (e.requestId === PANE) {
|
|
419
421
|
try {
|
|
420
422
|
const started = Date.now()
|
|
421
|
-
const
|
|
423
|
+
const data = e.data as { pick?: unknown; press?: unknown } | null
|
|
424
|
+
const id = data?.pick
|
|
422
425
|
const s: PanelState = (await $.state.get(REF)).value ?? EMPTY
|
|
423
|
-
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($)
|
|
424
429
|
} catch {
|
|
425
430
|
/* Fail open: the card stays as it was. */
|
|
426
431
|
}
|
|
@@ -449,6 +454,11 @@ export const register: Register = on => {
|
|
|
449
454
|
// Any surface but the terminal is the desktop app's: it sets the question as a heading, spaces the
|
|
450
455
|
// answers apart and draws the mark in boxes. The terminal keeps its text layout.
|
|
451
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'
|
|
452
462
|
// The mark is drawn in text, as Claude Code draws its own, so it shows in every terminal. The
|
|
453
463
|
// desktop app sets text in a proportional font with its own line height, so glyph cells do not
|
|
454
464
|
// tile there: it gets the same grid as filled boxes, one per run of a colour. A cell is one unit
|
|
@@ -467,6 +477,11 @@ export const register: Register = on => {
|
|
|
467
477
|
</Box>
|
|
468
478
|
))}
|
|
469
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>
|
|
470
485
|
) : (
|
|
471
486
|
<Box flexDirection="column" flexShrink={0}>
|
|
472
487
|
{boxRows().map((runs, y) => (
|
|
@@ -528,7 +543,7 @@ export const register: Register = on => {
|
|
|
528
543
|
<Box key="demo-bar" flexDirection="column" gap={app ? 1 : 0}>
|
|
529
544
|
<Text color="warning">{STR.demoBanner}</Text>
|
|
530
545
|
<Text dimColor>{s.demo.last}</Text>
|
|
531
|
-
{
|
|
546
|
+
{desktop ? (
|
|
532
547
|
<Box flexDirection="row" gap={2} alignItems="center">
|
|
533
548
|
<Text dimColor>{STR.demoStyleLabel}</Text>
|
|
534
549
|
<Button key="style-button" label={STR.demoStyleButton} variant={s.demo.style === 'button' ? 'primary' : 'secondary'} onPress={() => setStyle($, 'button')} />
|
|
@@ -604,18 +619,21 @@ export const register: Register = on => {
|
|
|
604
619
|
{note && !app ? <Text wrap="wrap" dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{`\n ${note}`}</Text> : null}
|
|
605
620
|
</Button>
|
|
606
621
|
)
|
|
607
|
-
|
|
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 })
|
|
608
626
|
const options = q.options.map((o, i) => {
|
|
609
627
|
const chosen = !s.draft.other && s.draft.picked === o.id
|
|
610
|
-
// The whole card is one pressable region, so a click on its padding or note picks too (card.tsx).
|
|
611
628
|
if (wholeCard) {
|
|
612
|
-
const props
|
|
629
|
+
const props = cardProps(o.id, { label: plainLabel(o.label), note: o.note ? plainLabel(o.note) : '', n: i + 1, chosen })
|
|
613
630
|
return <Client key={`card-${o.id}`} module="./card.tsx" width="100%" props={props} />
|
|
614
631
|
}
|
|
615
632
|
return card(
|
|
616
633
|
o.id,
|
|
617
634
|
chosen,
|
|
618
|
-
|
|
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),
|
|
619
637
|
// The desktop app draws only a Button's label, not its inline children, so there the note sits under the button.
|
|
620
638
|
app && o.note
|
|
621
639
|
? [<Text key={`note-${o.id}`} wrap="wrap" dimColor={!chosen} color={chosen ? PICKED.note : undefined}>{plainLabel(o.note)}</Text>]
|
|
@@ -630,13 +648,17 @@ export const register: Register = on => {
|
|
|
630
648
|
<Text color="warning" bold>
|
|
631
649
|
{STR.experimentBanner}
|
|
632
650
|
</Text>
|
|
633
|
-
{
|
|
651
|
+
{vector ? (
|
|
634
652
|
<Box flexDirection="column" gap={1}>
|
|
635
653
|
<Text dimColor>{STR.experimentSvg}</Text>
|
|
636
654
|
<Svg alt={STR.experimentImageAlt} width={208} height={96} source={demoSvg()} />
|
|
637
655
|
<Text dimColor>{STR.experimentSvgPng}</Text>
|
|
638
656
|
<Svg alt={STR.experimentImageAlt} width={208} height={96} source={demoSvgWithPng()} />
|
|
639
657
|
</Box>
|
|
658
|
+
) : app ? (
|
|
659
|
+
<Text dimColor wrap="wrap">
|
|
660
|
+
{STR.experimentNone}
|
|
661
|
+
</Text>
|
|
640
662
|
) : (
|
|
641
663
|
<Box flexDirection="column">
|
|
642
664
|
<Text dimColor wrap="wrap">
|
|
@@ -652,26 +674,48 @@ export const register: Register = on => {
|
|
|
652
674
|
stem,
|
|
653
675
|
<Box flexDirection="column" gap={app ? 2 : 0} marginTop={app ? 1 : 0}>
|
|
654
676
|
{options}
|
|
655
|
-
{
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
s.draft.other ? (
|
|
661
|
-
<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}>
|
|
662
684
|
<Input
|
|
663
685
|
key="other-text"
|
|
664
686
|
autoFocus
|
|
687
|
+
rows={3}
|
|
665
688
|
placeholder={STR.otherPlaceholder}
|
|
666
689
|
value={s.draft.text}
|
|
667
|
-
submitLabel="
|
|
690
|
+
submitLabel=""
|
|
668
691
|
onInput={(value: string) => (locked ? undefined : typeOther($, value))}
|
|
669
692
|
onSubmit={() => (locked ? undefined : keepOther($))}
|
|
670
693
|
/>
|
|
671
694
|
</Box>
|
|
672
|
-
) : null
|
|
673
|
-
|
|
674
|
-
|
|
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
|
+
)
|
|
675
719
|
)}
|
|
676
720
|
</Box>,
|
|
677
721
|
locked ? <Text dimColor>{STR.grading}</Text> : null,
|
|
@@ -689,7 +733,16 @@ export const register: Register = on => {
|
|
|
689
733
|
// Teach me has a row of its own, under Submit. A Button takes no fill, and a desktop draws a plain
|
|
690
734
|
// one without its grey chrome: the orange box is the button's body, and as a column it
|
|
691
735
|
// stretches the Button across its width, so all but the thin padding is pressable.
|
|
692
|
-
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 ? (
|
|
693
746
|
<Box key="teach-box" flexDirection="column" backgroundColor={TEACH.bg} borderStyle="round" borderColor={TEACH.bg} paddingX={1} paddingY={0.5}>
|
|
694
747
|
<Button key="teach" plain label={STR.teach} onPress={() => teach($)}>
|
|
695
748
|
<Text color={TEACH.text}>{STR.teach}</Text>
|
package/package.json
CHANGED