saterminal 0.1.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/.envrc +1 -0
- package/AGENTS.md +23 -0
- package/README.md +4 -0
- package/bun.lock +100 -0
- package/flake.lock +59 -0
- package/flake.nix +21 -0
- package/package.json +26 -0
- package/src/api.ts +78 -0
- package/src/focus.ts +231 -0
- package/src/main.ts +4 -0
- package/src/state.ts +203 -0
- package/src/text.ts +256 -0
- package/src/tui/app.ts +77 -0
- package/src/tui/focus-grid.ts +113 -0
- package/src/tui/history.ts +6 -0
- package/src/tui/input.ts +411 -0
- package/src/tui/kit.ts +29 -0
- package/src/tui/layout.ts +25 -0
- package/src/tui/pane-content.ts +150 -0
- package/src/tui/question.ts +104 -0
- package/src/tui/render.ts +618 -0
- package/src/tui/terminal.ts +110 -0
- package/src/tui/timer.ts +43 -0
- package/src/tui/types.ts +48 -0
- package/src/tui/viewport.ts +60 -0
- package/src/tui.ts +1 -0
- package/src/types/terminal-kit.d.ts +118 -0
- package/src/types.ts +52 -0
- package/test/api.test.ts +80 -0
- package/test/focus.test.ts +55 -0
- package/test/state.test.ts +120 -0
- package/test/text.test.ts +72 -0
- package/test/viewport.test.ts +39 -0
- package/tsconfig.json +13 -0
package/.envrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
use flake
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Git
|
|
2
|
+
|
|
3
|
+
- Commit regularly.
|
|
4
|
+
- Commit messages should split "what" from "why/how":
|
|
5
|
+
- First line: conventional commit summary.
|
|
6
|
+
- Following lines: concise reasons and implementation notes.
|
|
7
|
+
- Prefer backticks for paths, commands, package names, settings, and other literal identifiers that are easier to recognize that way.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
feat(darwin/packages): add `btop` and `macmon`
|
|
11
|
+
|
|
12
|
+
- `btop` and `macmon` provide terminal-based system monitoring
|
|
13
|
+
- install them through `environment.systemPackages` so they are available system-wide
|
|
14
|
+
- both packages resolve from the pinned nixpkgs set, so no unstable package source is needed
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
chore(repo): remove unused `documents/` directory
|
|
19
|
+
|
|
20
|
+
- no Nix modules, package config, or templates reference `documents/`
|
|
21
|
+
- the directory only contained standalone agent/document notes
|
|
22
|
+
- removing it keeps the repo focused on active configuration
|
|
23
|
+
```
|
package/README.md
ADDED
package/bun.lock
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "saterminal",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"he": "^1.2.0",
|
|
9
|
+
"node-html-parser": "^8.0.4",
|
|
10
|
+
"terminal-kit": "^3.1.2",
|
|
11
|
+
"word-wrap": "^1.2.5",
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/bun": "^1.2.22",
|
|
15
|
+
"@types/he": "^1.2.3",
|
|
16
|
+
"@types/word-wrap": "^1.2.1",
|
|
17
|
+
"typescript": "^5.9.3",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
"packages": {
|
|
22
|
+
"@cronvel/get-pixels": ["@cronvel/get-pixels@3.4.1", "", { "dependencies": { "jpeg-js": "^0.4.4", "ndarray": "^1.0.19", "ndarray-pack": "^1.1.1", "node-bitmap": "0.0.1", "omggif": "^1.0.10", "pngjs": "^6.0.0" } }, "sha512-gB5C5nDIacLUdsMuW8YsM9SzK3vaFANe4J11CVXpovpy7bZUGrcJKmc6m/0gWG789pKr6XSZY2aEetjFvSRw5g=="],
|
|
23
|
+
|
|
24
|
+
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
|
25
|
+
|
|
26
|
+
"@types/he": ["@types/he@1.2.3", "", {}, "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA=="],
|
|
27
|
+
|
|
28
|
+
"@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
|
|
29
|
+
|
|
30
|
+
"@types/word-wrap": ["@types/word-wrap@1.2.1", "", { "dependencies": { "word-wrap": "*" } }, "sha512-McFkme6x07Y1LCGT5YJW1VN5+NTGcNe4IrJwKuQLObdxEIh57SIU+lIo2cF1/Z0XDJrjtXRoynn4HrvS6pO/8A=="],
|
|
31
|
+
|
|
32
|
+
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
|
33
|
+
|
|
34
|
+
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
|
35
|
+
|
|
36
|
+
"chroma-js": ["chroma-js@2.6.0", "", {}, "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A=="],
|
|
37
|
+
|
|
38
|
+
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
|
39
|
+
|
|
40
|
+
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
|
41
|
+
|
|
42
|
+
"cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="],
|
|
43
|
+
|
|
44
|
+
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
|
45
|
+
|
|
46
|
+
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
|
47
|
+
|
|
48
|
+
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
|
49
|
+
|
|
50
|
+
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
|
51
|
+
|
|
52
|
+
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
|
53
|
+
|
|
54
|
+
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
|
55
|
+
|
|
56
|
+
"iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
|
|
57
|
+
|
|
58
|
+
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
|
59
|
+
|
|
60
|
+
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
|
61
|
+
|
|
62
|
+
"lazyness": ["lazyness@1.2.0", "", {}, "sha512-KenL6EFbwxBwRxG93t0gcUyi0Nw0Ub31FJKN1laA4UscdkL1K1AxUd0gYZdcLU3v+x+wcFi4uQKS5hL+fk500g=="],
|
|
63
|
+
|
|
64
|
+
"ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
|
|
65
|
+
|
|
66
|
+
"ndarray-pack": ["ndarray-pack@1.2.1", "", { "dependencies": { "cwise-compiler": "^1.1.2", "ndarray": "^1.0.13" } }, "sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g=="],
|
|
67
|
+
|
|
68
|
+
"nextgen-events": ["nextgen-events@1.5.3", "", {}, "sha512-P6qw6kenNXP+J9XlKJNi/MNHUQ+Lx5K8FEcSfX7/w8KJdZan5+BB5MKzuNgL2RTjHG1Svg8SehfseVEp8zAqwA=="],
|
|
69
|
+
|
|
70
|
+
"node-bitmap": ["node-bitmap@0.0.1", "", {}, "sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA=="],
|
|
71
|
+
|
|
72
|
+
"node-html-parser": ["node-html-parser@8.0.4", "", { "dependencies": { "css-select": "^5.1.0", "entities": "^8.0.0" } }, "sha512-w2YxujN/TqrSIWGVNW/fVgKKfAyQeHMXCvnKZI0owLnfP0tfjdLecUoy9zhOMZGoEFk6eP5qSDyvUarjPl3bwQ=="],
|
|
73
|
+
|
|
74
|
+
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
|
75
|
+
|
|
76
|
+
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
|
77
|
+
|
|
78
|
+
"pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
|
79
|
+
|
|
80
|
+
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
|
81
|
+
|
|
82
|
+
"seventh": ["seventh@0.9.4", "", { "dependencies": { "setimmediate": "^1.0.5" } }, "sha512-O85mosi4sOfxG+slvqy0j7zLuFD4ylUgEMt7Pvt9Q/wnwNwG/6MNnHKzV9JkAoPoPM26t/DLFn17p7o7u5kIBA=="],
|
|
83
|
+
|
|
84
|
+
"string-kit": ["string-kit@0.19.5", "", {}, "sha512-MqNWtQQw6xMFbOSa26HfyS5uyDu1tR4OEPz44VDP6JWa/S0E0N11uw8CXrPt5hme/tBXRp5AmoIvFl5B9lt7PA=="],
|
|
85
|
+
|
|
86
|
+
"terminal-kit": ["terminal-kit@3.1.3", "", { "dependencies": { "@cronvel/get-pixels": "^3.4.1", "chroma-js": "^2.4.2", "lazyness": "^1.2.0", "ndarray": "^1.0.19", "nextgen-events": "^1.5.3", "seventh": "^0.9.4", "string-kit": "^0.19.5", "tree-kit": "^0.8.10" } }, "sha512-URPwQqXe/T5dZoD4qBHUO7eS+Vtf0PjliCftJU2EPaF5uVw/QG1zqgLy5kqwTrn1ix9e9HtMgMKAnzgaAnr3yA=="],
|
|
87
|
+
|
|
88
|
+
"tree-kit": ["tree-kit@0.8.10", "", {}, "sha512-mwuV3lHL+utI9z7vxah/27wrMJprx925xhkw1N4KuGa1dqIi0DLHWfXJpHEyR+ZI0Ij80zN58ztiGp3KlH0wtw=="],
|
|
89
|
+
|
|
90
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
91
|
+
|
|
92
|
+
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
|
93
|
+
|
|
94
|
+
"uniq": ["uniq@1.0.1", "", {}, "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="],
|
|
95
|
+
|
|
96
|
+
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
|
97
|
+
|
|
98
|
+
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
|
99
|
+
}
|
|
100
|
+
}
|
package/flake.lock
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nodes": {
|
|
3
|
+
"flake-utils": {
|
|
4
|
+
"inputs": {
|
|
5
|
+
"systems": "systems"
|
|
6
|
+
},
|
|
7
|
+
"locked": {
|
|
8
|
+
"lastModified": 1731533236,
|
|
9
|
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
10
|
+
"owner": "numtide",
|
|
11
|
+
"repo": "flake-utils",
|
|
12
|
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
13
|
+
"type": "github"
|
|
14
|
+
},
|
|
15
|
+
"original": {
|
|
16
|
+
"owner": "numtide",
|
|
17
|
+
"repo": "flake-utils",
|
|
18
|
+
"type": "github"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"nixpkgs": {
|
|
22
|
+
"locked": {
|
|
23
|
+
"lastModified": 1781577229,
|
|
24
|
+
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
|
|
25
|
+
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
|
|
26
|
+
"revCount": 1017464,
|
|
27
|
+
"type": "tarball",
|
|
28
|
+
"url": "https://api.flakehub.com/f/pinned/DeterminateSystems/nixpkgs-weekly/0.1.1017464%2Brev-567a49d1913ce81ac6e9582e3553dd90a955875f/019f1749-32d6-7283-9e9a-861bd8478e6a/source.tar.gz"
|
|
29
|
+
},
|
|
30
|
+
"original": {
|
|
31
|
+
"type": "tarball",
|
|
32
|
+
"url": "https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/0.tar.gz"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"root": {
|
|
36
|
+
"inputs": {
|
|
37
|
+
"flake-utils": "flake-utils",
|
|
38
|
+
"nixpkgs": "nixpkgs"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"systems": {
|
|
42
|
+
"locked": {
|
|
43
|
+
"lastModified": 1681028828,
|
|
44
|
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
45
|
+
"owner": "nix-systems",
|
|
46
|
+
"repo": "default",
|
|
47
|
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
48
|
+
"type": "github"
|
|
49
|
+
},
|
|
50
|
+
"original": {
|
|
51
|
+
"owner": "nix-systems",
|
|
52
|
+
"repo": "default",
|
|
53
|
+
"type": "github"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"root": "root",
|
|
58
|
+
"version": 7
|
|
59
|
+
}
|
package/flake.nix
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
inputs = {
|
|
3
|
+
nixpkgs.url = "https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/0.tar.gz";
|
|
4
|
+
flake-utils.url = "github:numtide/flake-utils";
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
outputs = { nixpkgs, flake-utils, ... }:
|
|
8
|
+
flake-utils.lib.eachDefaultSystem (
|
|
9
|
+
system:
|
|
10
|
+
let
|
|
11
|
+
pkgs = import nixpkgs { inherit system; };
|
|
12
|
+
in
|
|
13
|
+
{
|
|
14
|
+
devShells.default = pkgs.mkShell {
|
|
15
|
+
packages = with pkgs; [
|
|
16
|
+
bun
|
|
17
|
+
];
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
);
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "saterminal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"sat": "./src/main.ts"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "bun run src/main.ts",
|
|
10
|
+
"start": "bun run src/main.ts",
|
|
11
|
+
"test": "bun test",
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"he": "^1.2.0",
|
|
16
|
+
"node-html-parser": "^8.0.4",
|
|
17
|
+
"terminal-kit": "^3.1.2",
|
|
18
|
+
"word-wrap": "^1.2.5"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/bun": "^1.2.22",
|
|
22
|
+
"@types/he": "^1.2.3",
|
|
23
|
+
"@types/word-wrap": "^1.2.1",
|
|
24
|
+
"typescript": "^5.9.3"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { defaultFocus, domainsForSkills } from "./focus.ts";
|
|
2
|
+
import type { Focus, PracticeQuestion, QuestionDetail, QuestionMeta } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const baseUrl = "https://practicesat.vercel.app/api";
|
|
5
|
+
|
|
6
|
+
type ApiEnvelope<T> = {
|
|
7
|
+
success: boolean;
|
|
8
|
+
data: T;
|
|
9
|
+
message?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function fetchQuestionBank(excludeIds: Iterable<string> = [], focus: Focus = defaultFocus): Promise<QuestionMeta[]> {
|
|
13
|
+
const params = focusParams(focus);
|
|
14
|
+
const excluded = [...excludeIds].filter(Boolean);
|
|
15
|
+
if (excluded.length > 0) {
|
|
16
|
+
params.set("excludeIds", excluded.join(","));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const response = await fetchJson<ApiEnvelope<QuestionMeta[]>>(`${baseUrl}/get-questions?${params}`);
|
|
20
|
+
if (!response.success || !Array.isArray(response.data)) {
|
|
21
|
+
throw new Error(response.message || "Question bank fetch failed.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return response.data.filter((item) => item.questionId && item.external_id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function fetchQuestion(id: string): Promise<QuestionDetail> {
|
|
28
|
+
const response = await fetchJson<ApiEnvelope<QuestionDetail>>(`${baseUrl}/question/${id}`);
|
|
29
|
+
if (!response.success || !response.data) {
|
|
30
|
+
throw new Error(response.message || `Question ${id} fetch failed.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return response.data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function fetchPracticeQuestion(attemptedIds: Iterable<string>, focus: Focus = defaultFocus): Promise<PracticeQuestion> {
|
|
37
|
+
const bank = await fetchQuestionBank(attemptedIds, focus);
|
|
38
|
+
if (bank.length === 0) {
|
|
39
|
+
throw new Error("No unanswered questions matched the current filters.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const meta = bank[Math.floor(Math.random() * bank.length)];
|
|
43
|
+
const detail = await fetchQuestion(meta.external_id);
|
|
44
|
+
return { meta, detail };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function findQuestionByShortId(questionId: string): Promise<PracticeQuestion | undefined> {
|
|
48
|
+
const bank = await fetchQuestionBank();
|
|
49
|
+
const meta = bank.find((item) => item.questionId === questionId);
|
|
50
|
+
if (!meta) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { meta, detail: await fetchQuestion(meta.external_id) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function focusParams(focus: Focus): URLSearchParams {
|
|
58
|
+
return new URLSearchParams({
|
|
59
|
+
assessment: "SAT",
|
|
60
|
+
domains: domainsForSkills(focus.skills).join(","),
|
|
61
|
+
difficulties: focus.difficulties.join(","),
|
|
62
|
+
skills: focus.skills.join(","),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fetchJson<T>(url: string): Promise<T> {
|
|
67
|
+
const response = await fetch(url, {
|
|
68
|
+
headers: {
|
|
69
|
+
accept: "application/json",
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (await response.json()) as T;
|
|
78
|
+
}
|
package/src/focus.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { Difficulty, Domain, Focus, Skill } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const difficultyOptions = ["E", "M", "H"] as const satisfies readonly Difficulty[];
|
|
4
|
+
export const domainOptions = ["INI", "CAS", "EOI", "SEC"] as const satisfies readonly Domain[];
|
|
5
|
+
export const skillOptions = ["CID", "INF", "COE", "WIC", "TSP", "CTC", "SYN", "TRA", "BOU", "FSS"] as const satisfies readonly Skill[];
|
|
6
|
+
|
|
7
|
+
export const skillsByDomain: Record<Domain, readonly Skill[]> = {
|
|
8
|
+
INI: ["CID", "INF", "COE"],
|
|
9
|
+
CAS: ["WIC", "TSP", "CTC"],
|
|
10
|
+
EOI: ["SYN", "TRA"],
|
|
11
|
+
SEC: ["BOU", "FSS"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const defaultFocus: Focus = {
|
|
15
|
+
difficulties: ["M", "H"],
|
|
16
|
+
domains: [...domainOptions],
|
|
17
|
+
skills: [...skillOptions],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type FocusGroup = "difficulties" | "domains" | "skills";
|
|
21
|
+
|
|
22
|
+
export type FocusRow =
|
|
23
|
+
| { kind: "header"; label: string }
|
|
24
|
+
| {
|
|
25
|
+
kind: "option";
|
|
26
|
+
group: FocusGroup;
|
|
27
|
+
value: Difficulty | Domain | Skill;
|
|
28
|
+
label: string;
|
|
29
|
+
checked: boolean;
|
|
30
|
+
partial?: boolean;
|
|
31
|
+
depth: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function normalizeFocus(value: unknown): Focus {
|
|
35
|
+
const raw = value && typeof value === "object" ? value as Partial<Record<keyof Focus, unknown>> : {};
|
|
36
|
+
const skills = normalizeSkills(raw.skills, raw.domains);
|
|
37
|
+
return {
|
|
38
|
+
difficulties: normalizeSelection(raw.difficulties, difficultyOptions, defaultFocus.difficulties),
|
|
39
|
+
domains: domainsForSkills(skills),
|
|
40
|
+
skills,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function domainsForSkills(skills: readonly Skill[]): Domain[] {
|
|
45
|
+
return domainOptions.filter((domain) => skillsForDomain(domain).some((skill) => skills.includes(skill)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function skillsForDomain(domain: Domain): readonly Skill[] {
|
|
49
|
+
return skillsByDomain[domain];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function focusSummary(focus: Focus): string {
|
|
53
|
+
const difficulty = focus.difficulties.join(",");
|
|
54
|
+
const skillCount = focus.skills.length;
|
|
55
|
+
const domainCount = focus.domains.length;
|
|
56
|
+
return `${skillCount} skills · ${difficulty} · ${domainCount} domains`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function focusRows(focus: Focus): FocusRow[] {
|
|
60
|
+
return [
|
|
61
|
+
{ kind: "header", label: "Difficulty" },
|
|
62
|
+
...difficultyOptions.map((value) => optionRow("difficulties", value, difficultyLabels[value], focus.difficulties.includes(value), 0)),
|
|
63
|
+
{ kind: "header", label: "Reading and Writing" },
|
|
64
|
+
...domainOptions.flatMap((domain) => domainRows(domain, focus)),
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function toggleFocusRow(focus: Focus, row: FocusRow | undefined): Focus {
|
|
69
|
+
if (!row || row.kind !== "option") {
|
|
70
|
+
return focus;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (row.group === "domains") {
|
|
74
|
+
return toggleDomainFocus(focus, row.value as Domain);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (row.group === "difficulties") {
|
|
78
|
+
return toggleDifficultyFocus(focus, row.value as Difficulty);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return toggleSkillFocus(focus, row.value as Skill);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function nextFocusableIndex(rows: FocusRow[], current: number): number {
|
|
85
|
+
for (let index = Math.min(current + 1, rows.length - 1); index < rows.length; index++) {
|
|
86
|
+
if (rows[index]?.kind === "option") {
|
|
87
|
+
return index;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return current;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function previousFocusableIndex(rows: FocusRow[], current: number): number {
|
|
94
|
+
for (let index = Math.max(current - 1, 0); index >= 0; index--) {
|
|
95
|
+
if (rows[index]?.kind === "option") {
|
|
96
|
+
return index;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return current;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function domainRows(domain: Domain, focus: Focus): FocusRow[] {
|
|
103
|
+
const skills = skillsForDomain(domain);
|
|
104
|
+
const selected = skills.filter((skill) => focus.skills.includes(skill));
|
|
105
|
+
return [
|
|
106
|
+
optionRow("domains", domain, domainLabels[domain], selected.length > 0, 0, selected.length > 0 && selected.length < skills.length),
|
|
107
|
+
...skills.map((skill) => optionRow("skills", skill, skillLabels[skill], focus.skills.includes(skill), 1)),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function optionRow(
|
|
112
|
+
group: FocusGroup,
|
|
113
|
+
value: Difficulty | Domain | Skill,
|
|
114
|
+
label: string,
|
|
115
|
+
checked: boolean,
|
|
116
|
+
depth: number,
|
|
117
|
+
partial = false,
|
|
118
|
+
): FocusRow {
|
|
119
|
+
return { kind: "option", group, value, label: `${value} ${label}`, checked, partial, depth };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toggleDifficultyFocus(focus: Focus, difficulty: Difficulty): Focus {
|
|
123
|
+
const selected = focus.difficulties.includes(difficulty);
|
|
124
|
+
if (selected && focus.difficulties.length === 1) {
|
|
125
|
+
return focus;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
...focus,
|
|
130
|
+
difficulties: selected
|
|
131
|
+
? focus.difficulties.filter((value) => value !== difficulty)
|
|
132
|
+
: unique([...focus.difficulties, difficulty]),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toggleSkillFocus(focus: Focus, skill: Skill): Focus {
|
|
137
|
+
const selected = focus.skills.includes(skill);
|
|
138
|
+
if (selected && focus.skills.length === 1) {
|
|
139
|
+
return focus;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const skills = selected ? focus.skills.filter((value) => value !== skill) : unique([...focus.skills, skill]);
|
|
143
|
+
return {
|
|
144
|
+
...focus,
|
|
145
|
+
domains: domainsForSkills(skills),
|
|
146
|
+
skills,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toggleDomainFocus(focus: Focus, domain: Domain): Focus {
|
|
151
|
+
const domainSkills = skillsForDomain(domain);
|
|
152
|
+
const allSelected = domainSkills.every((skill) => focus.skills.includes(skill));
|
|
153
|
+
|
|
154
|
+
if (allSelected) {
|
|
155
|
+
const remaining = focus.skills.filter((skill) => !domainSkills.includes(skill));
|
|
156
|
+
if (remaining.length === 0) {
|
|
157
|
+
return focus;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
...focus,
|
|
162
|
+
domains: domainsForSkills(remaining),
|
|
163
|
+
skills: remaining,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const skills = unique([...focus.skills, ...domainSkills]);
|
|
168
|
+
return {
|
|
169
|
+
...focus,
|
|
170
|
+
domains: domainsForSkills(skills),
|
|
171
|
+
skills,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeSelection<T extends string>(value: unknown, options: readonly T[], fallback: readonly T[]): T[] {
|
|
176
|
+
if (!Array.isArray(value)) {
|
|
177
|
+
return [...fallback];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const selected = options.filter((option) => value.includes(option));
|
|
181
|
+
return selected.length > 0 ? selected : [...fallback];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeSkills(value: unknown, domains: unknown): Skill[] {
|
|
185
|
+
if (Array.isArray(value)) {
|
|
186
|
+
const selected = skillOptions.filter((option) => value.includes(option));
|
|
187
|
+
if (selected.length > 0) {
|
|
188
|
+
return selected;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (Array.isArray(domains)) {
|
|
193
|
+
const selectedDomains = domainOptions.filter((option) => domains.includes(option));
|
|
194
|
+
const selectedSkills = selectedDomains.flatMap((domain) => [...skillsForDomain(domain)]);
|
|
195
|
+
if (selectedSkills.length > 0) {
|
|
196
|
+
return selectedSkills;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [...defaultFocus.skills];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function unique<T>(values: T[]): T[] {
|
|
204
|
+
return values.filter((value, index, list) => list.indexOf(value) === index);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const difficultyLabels: Record<Difficulty, string> = {
|
|
208
|
+
E: "Easy",
|
|
209
|
+
M: "Medium",
|
|
210
|
+
H: "Hard",
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const domainLabels: Record<Domain, string> = {
|
|
214
|
+
INI: "Information and Ideas",
|
|
215
|
+
CAS: "Craft and Structure",
|
|
216
|
+
EOI: "Expression of Ideas",
|
|
217
|
+
SEC: "Standard English Conventions",
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const skillLabels: Record<Skill, string> = {
|
|
221
|
+
CID: "Central Ideas and Details",
|
|
222
|
+
INF: "Inferences",
|
|
223
|
+
COE: "Command of Evidence",
|
|
224
|
+
WIC: "Words in Context",
|
|
225
|
+
TSP: "Text Structure and Purpose",
|
|
226
|
+
CTC: "Cross-Text Connections",
|
|
227
|
+
SYN: "Transitions",
|
|
228
|
+
TRA: "Rhetorical Synthesis",
|
|
229
|
+
BOU: "Boundaries",
|
|
230
|
+
FSS: "Form, Structure, and Sense",
|
|
231
|
+
};
|
package/src/main.ts
ADDED