@itcase/config 1.6.80 → 1.6.82
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 +4 -2
- package/husky-hooks/_lib.sh +3 -2
- package/lint-staged/index.js +15 -4
- package/lint-staged-ts/index.js +16 -6
- package/package.json +15 -15
- package/smart-commit/cli.js +361 -50
- package/smart-commit/pacman.js +140 -0
- package/smart-commit/upyachka.js +229 -0
package/README.md
CHANGED
|
@@ -90,8 +90,10 @@ npm install --save-dev commitizen @commitlint/cz-commitlint
|
|
|
90
90
|
`smart-commit` — Cursor Agent (`ask`) планирует коммиты по `SCOPES`, печатает сообщения, спрашивает `[y/N]`. Перед коммитом ждёт и снимает устаревший `.git/index.lock`, если его никто не держит. Lockfile (`package-lock.json` и аналоги) всегда кладётся в тот же коммит, что и соседний `package.json`; при одном только bump deps агент не вызывается.
|
|
91
91
|
|
|
92
92
|
```text
|
|
93
|
-
[
|
|
93
|
+
[random pixel animation]
|
|
94
94
|
Drafting commit messages… (12s)
|
|
95
|
+
Agent started (Composer)
|
|
96
|
+
Reading src/foo.ts
|
|
95
97
|
|
|
96
98
|
Commits:
|
|
97
99
|
• feat(other): add smart-commit cli
|
|
@@ -146,7 +148,7 @@ CLI ставится в `~/.local/bin`. Логин в Cursor IDE этот CLI н
|
|
|
146
148
|
|
|
147
149
|
Обновление CLI: `agent update`.
|
|
148
150
|
|
|
149
|
-
Пока Agent думает, в терминале крутится pixel-анимация (случайно nyan cat
|
|
151
|
+
Пока Agent думает, в терминале крутится pixel-анимация (случайно nyan cat, dancing banana, упячка или Pac-Man) + статус `(Ns)`, а под ними живой лог агента (`log-update`). Пока процесс агента жив, CLI его не убивает по таймауту. Принудительно: `--cat` / `--banana` / `--upyachka` / `--pacman`. Для полного лога: `npm run smart-commit -- --verbose --dry-run`.
|
|
150
152
|
|
|
151
153
|
> **Важно:** в npm есть одноимённый пакет `smart-commit`. Используйте `npm run smart-commit` из проекта с `@itcase/config` — bin берётся из `node_modules`. `npx smart-commit` без локальной установки может подтянуть чужой пакет; CLI перед запуском проверяет, что выполняется именно `@itcase/config`.
|
|
152
154
|
|
package/husky-hooks/_lib.sh
CHANGED
|
@@ -48,10 +48,11 @@ itcase_conflict_marker_files() {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
itcase_run_lint_staged() {
|
|
51
|
+
# ACMR drops deletions; include D so removed files stay in the commit.
|
|
51
52
|
if [ -f "./node_modules/lint-staged/bin/lint-staged.js" ]; then
|
|
52
|
-
node ./node_modules/lint-staged/bin/lint-staged.js
|
|
53
|
+
node ./node_modules/lint-staged/bin/lint-staged.js --diff-filter=ACDMR
|
|
53
54
|
return $?
|
|
54
55
|
fi
|
|
55
56
|
|
|
56
|
-
npx lint-staged
|
|
57
|
+
npx lint-staged --diff-filter=ACDMR
|
|
57
58
|
}
|
package/lint-staged/index.js
CHANGED
|
@@ -2,10 +2,21 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
|
-
'*.(js|jsx|ts|tsx)': (files) =>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
'*.(js|jsx|ts|tsx)': (files) => {
|
|
6
|
+
const existing = files.filter((file) => existsSync(file))
|
|
7
|
+
if (!existing.length) return 'true'
|
|
8
|
+
return `npx eslint --fix --color ${existing.join(' ')}`
|
|
9
|
+
},
|
|
10
|
+
'*.css': (files) => {
|
|
11
|
+
const existing = files.filter((file) => existsSync(file))
|
|
12
|
+
if (!existing.length) return 'true'
|
|
13
|
+
return `npx stylelint --fix --color --formatter verbose ${existing.join(' ')}`
|
|
14
|
+
},
|
|
15
|
+
'*.mdx': (files) => {
|
|
16
|
+
const existing = files.filter((file) => existsSync(file))
|
|
17
|
+
if (!existing.length) return 'true'
|
|
18
|
+
return `npx eslint --fix --color ${existing.join(' ')}`
|
|
19
|
+
},
|
|
9
20
|
'package.json': (files) => {
|
|
10
21
|
// Проверяем, есть ли package.json в корне репозитория
|
|
11
22
|
const rootPackageJson = resolve(process.cwd(), 'package.json')
|
package/lint-staged-ts/index.js
CHANGED
|
@@ -3,17 +3,27 @@ import { resolve } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
5
|
'*.(ts|tsx)': (files) => {
|
|
6
|
-
const
|
|
6
|
+
const existing = files.filter((file) => existsSync(file))
|
|
7
|
+
const tsFiles = existing.filter((file) => /\.(ts|tsx)$/.test(file))
|
|
7
8
|
const commands = []
|
|
8
9
|
if (tsFiles.length > 0) {
|
|
9
10
|
commands.push('tsc --noEmit --pretty')
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
if (existing.length > 0) {
|
|
13
|
+
commands.push(`npx eslint --fix ${existing.join(' ')}`)
|
|
14
|
+
}
|
|
15
|
+
return commands.length ? commands : 'true'
|
|
16
|
+
},
|
|
17
|
+
'*.css': (files) => {
|
|
18
|
+
const existing = files.filter((file) => existsSync(file))
|
|
19
|
+
if (!existing.length) return 'true'
|
|
20
|
+
return `npx stylelint --fix --color --formatter verbose ${existing.join(' ')}`
|
|
21
|
+
},
|
|
22
|
+
'*.mdx': (files) => {
|
|
23
|
+
const existing = files.filter((file) => existsSync(file))
|
|
24
|
+
if (!existing.length) return 'true'
|
|
25
|
+
return `npx eslint --fix ${existing.join(' ')}`
|
|
13
26
|
},
|
|
14
|
-
'*.css': (files) =>
|
|
15
|
-
`npx stylelint --fix --color --formatter verbose ${files.join(' ')}`,
|
|
16
|
-
'*.mdx': (files) => `npx eslint --fix ${files.join(' ')}`,
|
|
17
27
|
'package.json': (files) => {
|
|
18
28
|
// Проверяем, есть ли package.json в корне репозитория
|
|
19
29
|
const rootPackageJson = resolve(process.cwd(), 'package.json')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itcase/config",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.82",
|
|
4
4
|
"author": "ITCase",
|
|
5
5
|
"description": "ITCase Config",
|
|
6
6
|
"engines": {
|
|
@@ -63,23 +63,23 @@
|
|
|
63
63
|
"@svgr/plugin-jsx": "^8.1.0",
|
|
64
64
|
"@svgr/plugin-svgo": "^8.1.0",
|
|
65
65
|
"@svgr/webpack": "^8.1.0",
|
|
66
|
-
"autoprefixer": "^10.5.
|
|
66
|
+
"autoprefixer": "^10.5.5",
|
|
67
67
|
"chokidar-cli": "^3.0.0",
|
|
68
68
|
"commitizen": "^4.3.2",
|
|
69
69
|
"conventional-changelog-conventionalcommits": "9.3.1",
|
|
70
|
-
"cssnano": "^9.0.
|
|
71
|
-
"cssnano-preset-default": "^9.0.
|
|
72
|
-
"inquirer": "^14.2.
|
|
70
|
+
"cssnano": "^9.0.4",
|
|
71
|
+
"cssnano-preset-default": "^9.0.4",
|
|
72
|
+
"inquirer": "^14.2.2",
|
|
73
73
|
"log-update": "^8.0.0",
|
|
74
|
-
"postcss": "^8.5.
|
|
74
|
+
"postcss": "^8.5.28",
|
|
75
75
|
"postcss-aspect-ratio-polyfill": "^2.0.0",
|
|
76
76
|
"postcss-clamp": "^4.1.0",
|
|
77
|
-
"postcss-cli": "^
|
|
77
|
+
"postcss-cli": "^12.0.0",
|
|
78
78
|
"postcss-color-hsla-fallback": "^1.0.0",
|
|
79
79
|
"postcss-combine-duplicated-selectors": "^10.0.3",
|
|
80
80
|
"postcss-convert-unit": "^0.1.3",
|
|
81
81
|
"postcss-dark-theme-class": "^2.0.0",
|
|
82
|
-
"postcss-discard-duplicates": "^9.0.
|
|
82
|
+
"postcss-discard-duplicates": "^9.0.2",
|
|
83
83
|
"postcss-each": "^1.1.0",
|
|
84
84
|
"postcss-easings": "^4.0.0",
|
|
85
85
|
"postcss-extend-rule": "^4.0.0",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"postcss-nested-ancestors": "^3.0.0",
|
|
96
96
|
"postcss-normalize": "^13.0.1",
|
|
97
97
|
"postcss-prepend-imports": "^1.0.1",
|
|
98
|
-
"postcss-preset-env": "^11.5.
|
|
98
|
+
"postcss-preset-env": "^11.5.2",
|
|
99
99
|
"postcss-pxtorem": "^6.1.0",
|
|
100
100
|
"postcss-sort-media-queries": "^6.7.1",
|
|
101
101
|
"postcss-unit": "^0.0.3",
|
|
@@ -109,17 +109,17 @@
|
|
|
109
109
|
"rollup-plugin-peer-deps-external": "^2.2.4",
|
|
110
110
|
"rollup-preserve-directives": "^1.1.3",
|
|
111
111
|
"semantic-release": "^25.0.9",
|
|
112
|
-
"semantic-release-lerna": "^3.6.
|
|
112
|
+
"semantic-release-lerna": "^3.6.4"
|
|
113
113
|
},
|
|
114
114
|
"devDependencies": {
|
|
115
|
-
"@itcase/lint": "^1.1.
|
|
115
|
+
"@itcase/lint": "^1.1.123",
|
|
116
116
|
"eslint": "9.39.2",
|
|
117
117
|
"husky": "^9.1.7",
|
|
118
|
-
"inquirer": "^14.2.
|
|
119
|
-
"lint-staged": "^17.
|
|
118
|
+
"inquirer": "^14.2.2",
|
|
119
|
+
"lint-staged": "^17.5.0",
|
|
120
120
|
"prettier": "^3.9.6",
|
|
121
|
-
"storybook": "^10.
|
|
122
|
-
"stylelint": "^17.
|
|
121
|
+
"storybook": "^10.6.0",
|
|
122
|
+
"stylelint": "^17.15.0",
|
|
123
123
|
"typescript": "^6.0.3"
|
|
124
124
|
}
|
|
125
125
|
}
|
package/smart-commit/cli.js
CHANGED
|
@@ -17,9 +17,13 @@ import {
|
|
|
17
17
|
} from '../commitlint/commitlintCommon.js'
|
|
18
18
|
import { getBananaFrame } from './dancing-banana.js'
|
|
19
19
|
import { getNyanFrame } from './nyan-cat.js'
|
|
20
|
+
import { getPacmanFrame } from './pacman.js'
|
|
21
|
+
import { getUpyachkaFrame } from './upyachka.js'
|
|
20
22
|
|
|
21
23
|
const PACKAGE_NAME = '@itcase/config'
|
|
22
24
|
const CLI_RELATIVE = 'smart-commit/cli.js'
|
|
25
|
+
// Cheap Cursor Models pool — commit messages do not need Auto / frontier models.
|
|
26
|
+
const DEFAULT_MODEL = 'composer-2.5'
|
|
23
27
|
|
|
24
28
|
const MAX_DIFF = 80_000
|
|
25
29
|
const LOCKFILE_NAMES = new Set([
|
|
@@ -30,10 +34,14 @@ const LOCKFILE_NAMES = new Set([
|
|
|
30
34
|
'bun.lock',
|
|
31
35
|
'bun.lockb',
|
|
32
36
|
])
|
|
33
|
-
const AGENT_TIMEOUT_MS = 120_000
|
|
34
37
|
const AGENT_STEP_MS = 2_500
|
|
38
|
+
const AGENT_STILL_WORKING_S = 120
|
|
39
|
+
const AGENT_LOG_ROWS = 8
|
|
40
|
+
const AGENT_LOG_MAX = 160
|
|
35
41
|
const NYAN_FRAME_MS = 90
|
|
36
42
|
const BANANA_FRAME_MS = 100
|
|
43
|
+
const PACMAN_FRAME_MS = 120
|
|
44
|
+
const UPYACHKA_FRAME_MS = 100
|
|
37
45
|
const INDEX_LOCK_RETRIES = 8
|
|
38
46
|
const INDEX_LOCK_WAIT_MS = 250
|
|
39
47
|
const STALE_LOCK_MS = 1500
|
|
@@ -43,13 +51,15 @@ const AGENT_WAIT_STEPS = [
|
|
|
43
51
|
'Waiting for response',
|
|
44
52
|
]
|
|
45
53
|
const HELP = `smart-commit [--dry-run] [--yes|-y] [--type t] [--model id] [--verbose]
|
|
46
|
-
[--cat|--nyan] [--banana]
|
|
54
|
+
[--cat|--nyan] [--banana] [--upyachka] [--pacman]
|
|
47
55
|
|
|
48
56
|
Cursor Agent → commit messages by SCOPES → [y/N] → git commit
|
|
49
57
|
Run via: npm exec smart-commit (from project with @itcase/config)
|
|
50
58
|
Needs: agent login (or CURSOR_API_KEY) + commitlint.config.* with SCOPES
|
|
59
|
+
Default model: ${DEFAULT_MODEL} (override with --model / SMART_COMMIT_MODEL).
|
|
51
60
|
|
|
52
|
-
Progress sprite is random (nyan cat
|
|
61
|
+
Progress sprite is random (nyan cat, banana, upyachka, or Pac-Man).
|
|
62
|
+
Force with --cat, --banana, --upyachka, or --pacman. Agent log is below.
|
|
53
63
|
`
|
|
54
64
|
const AGENT_MISSING = `Cursor Agent CLI not found — smart-commit cannot run.
|
|
55
65
|
|
|
@@ -97,7 +107,7 @@ async function main(argv) {
|
|
|
97
107
|
(progress) =>
|
|
98
108
|
askAgent(
|
|
99
109
|
cwd,
|
|
100
|
-
option.model || process.env.SMART_COMMIT_MODEL ||
|
|
110
|
+
option.model || process.env.SMART_COMMIT_MODEL || DEFAULT_MODEL,
|
|
101
111
|
prompt,
|
|
102
112
|
option.verbose,
|
|
103
113
|
progress.onAgentActivity,
|
|
@@ -213,6 +223,8 @@ function parseArgs(argv) {
|
|
|
213
223
|
else if (arg === '--staged') option.staged = true
|
|
214
224
|
else if (arg === '--cat' || arg === '--nyan') option.sprite = 'cat'
|
|
215
225
|
else if (arg === '--banana') option.sprite = 'banana'
|
|
226
|
+
else if (arg === '--upyachka') option.sprite = 'upyachka'
|
|
227
|
+
else if (arg === '--pacman') option.sprite = 'pacman'
|
|
216
228
|
else if (arg === '--type') option.type = argv[++i] || ''
|
|
217
229
|
else if (arg === '--model') option.model = argv[++i] || ''
|
|
218
230
|
else if (arg.startsWith('--type=')) option.type = arg.slice(7)
|
|
@@ -223,8 +235,9 @@ function parseArgs(argv) {
|
|
|
223
235
|
}
|
|
224
236
|
|
|
225
237
|
function pickSprite(forced) {
|
|
226
|
-
|
|
227
|
-
|
|
238
|
+
const sprites = ['cat', 'banana', 'upyachka', 'pacman']
|
|
239
|
+
if (sprites.includes(forced)) return forced
|
|
240
|
+
return sprites[Math.floor(Math.random() * sprites.length)]
|
|
228
241
|
}
|
|
229
242
|
|
|
230
243
|
function assertItcaseConfigPackage(cwd) {
|
|
@@ -352,6 +365,7 @@ function listFiles(cwd, option) {
|
|
|
352
365
|
...new Set([
|
|
353
366
|
...lines(cwd, ['diff', '--cached', '--name-only', '--diff-filter=ACDMR']),
|
|
354
367
|
...lines(cwd, ['diff', '--name-only', '--diff-filter=ACDMR']),
|
|
368
|
+
...lines(cwd, ['ls-files', '--deleted']),
|
|
355
369
|
...lines(cwd, ['ls-files', '--others', '--exclude-standard']),
|
|
356
370
|
]),
|
|
357
371
|
]
|
|
@@ -385,14 +399,15 @@ Scopes (exact): ${buildScopeEnum(scopes).join(', ')}
|
|
|
385
399
|
Subject rules:
|
|
386
400
|
${promptSubjectRules.map((rule) => `- ${rule}`).join('\n')}
|
|
387
401
|
Prefer one commit when changes belong to the same feature.
|
|
388
|
-
Split by scope. Each listed file in exactly one commit — including new/untracked files (??).
|
|
402
|
+
Split by scope. Each listed file in exactly one commit — including deleted files (D) and new/untracked files (??).
|
|
389
403
|
Put each lockfile in the same commit as its sibling package.json. Do not make a lockfile-only commit.
|
|
390
404
|
Do not add Co-authored-by or Made-with trailers.
|
|
391
405
|
${type ? `Prefer type "${type}".\n` : ''}Files:
|
|
392
406
|
${files
|
|
393
407
|
.map((file) => {
|
|
394
408
|
const status =
|
|
395
|
-
statusByFile.get(file) ||
|
|
409
|
+
statusByFile.get(file) ||
|
|
410
|
+
(isUntracked(cwd, file) ? '??' : isOnDisk(cwd, file) ? ' M' : 'D')
|
|
396
411
|
const lockNote = isLockfile(file) ? ', lockfile' : ''
|
|
397
412
|
return `- ${file} (${status}${lockNote})`
|
|
398
413
|
})
|
|
@@ -460,49 +475,109 @@ async function withAgentProgress(context, callback) {
|
|
|
460
475
|
let phase = 'early'
|
|
461
476
|
let receivedChars = 0
|
|
462
477
|
let frameIndex = 0
|
|
478
|
+
const logLines = []
|
|
479
|
+
const streamByKey = { assistant: -1, thinking: -1 }
|
|
463
480
|
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
481
|
+
const elapsedSeconds = () => Math.floor((Date.now() - startedAt) / 1000)
|
|
482
|
+
|
|
483
|
+
const onAgentActivity = (activity = {}) => {
|
|
484
|
+
if (activity.charCount > 0) {
|
|
485
|
+
receivedChars = activity.charCount
|
|
467
486
|
phase = 'receiving'
|
|
468
487
|
}
|
|
488
|
+
if (!activity.log) return
|
|
489
|
+
if (activity.stream) {
|
|
490
|
+
const key = activity.streamKey || 'assistant'
|
|
491
|
+
const index = streamByKey[key]
|
|
492
|
+
if (index >= 0 && index < logLines.length) logLines[index] = activity.log
|
|
493
|
+
else {
|
|
494
|
+
logLines.push(activity.log)
|
|
495
|
+
streamByKey[key] = logLines.length - 1
|
|
496
|
+
}
|
|
497
|
+
} else if (logLines[logLines.length - 1] !== activity.log) {
|
|
498
|
+
logLines.push(activity.log)
|
|
499
|
+
streamByKey.assistant = -1
|
|
500
|
+
}
|
|
501
|
+
if (phase !== 'receiving') phase = 'working'
|
|
469
502
|
}
|
|
470
503
|
|
|
471
504
|
const currentLabel = () => {
|
|
505
|
+
const elapsed = elapsedSeconds()
|
|
472
506
|
if (phase === 'receiving') {
|
|
473
507
|
return `Receiving response (${receivedChars} chars)`
|
|
474
508
|
}
|
|
475
|
-
if (
|
|
509
|
+
if (elapsed >= AGENT_STILL_WORKING_S) return 'Agent still working'
|
|
510
|
+
if (phase === 'waiting' || phase === 'working') {
|
|
476
511
|
return AGENT_WAIT_STEPS[waitStepIndex % AGENT_WAIT_STEPS.length]
|
|
477
512
|
}
|
|
478
513
|
return earlySteps[Math.min(earlyStepIndex, earlySteps.length - 1)]
|
|
479
514
|
}
|
|
480
515
|
|
|
481
516
|
const formatText = () => {
|
|
482
|
-
const
|
|
483
|
-
|
|
517
|
+
const text = `${currentLabel()}… (${elapsedSeconds()}s)`
|
|
518
|
+
const width = process.stderr.columns || process.stdout.columns || 80
|
|
519
|
+
return clipLog(text, width)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const reportActivity = (activity) => {
|
|
523
|
+
onAgentActivity(activity)
|
|
524
|
+
if (
|
|
525
|
+
(!process.stderr.isTTY || context.verbose) &&
|
|
526
|
+
activity.log &&
|
|
527
|
+
!activity.stream
|
|
528
|
+
) {
|
|
529
|
+
process.stderr.write(`${activity.log}\n`)
|
|
530
|
+
}
|
|
484
531
|
}
|
|
485
532
|
|
|
486
533
|
if (!process.stderr.isTTY || context.verbose) {
|
|
487
534
|
process.stderr.write(`${formatText()}\n`)
|
|
488
|
-
|
|
535
|
+
const waitTimer = setInterval(() => {
|
|
536
|
+
if (phase === 'receiving') return
|
|
537
|
+
process.stderr.write(`Thinking… (${elapsedSeconds()}s)\n`)
|
|
538
|
+
}, AGENT_STEP_MS)
|
|
539
|
+
try {
|
|
540
|
+
return await callback({ onAgentActivity: reportActivity })
|
|
541
|
+
} finally {
|
|
542
|
+
clearInterval(waitTimer)
|
|
543
|
+
}
|
|
489
544
|
}
|
|
490
545
|
|
|
491
546
|
const columns = process.stderr.columns || process.stdout.columns || 80
|
|
492
547
|
const rows = process.stderr.rows || process.stdout.rows || 24
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
548
|
+
const logRows = Math.min(AGENT_LOG_ROWS, Math.max(3, rows - 10))
|
|
549
|
+
const spriteRows = Math.max(10, rows - logRows)
|
|
550
|
+
const sprite = {
|
|
551
|
+
banana: {
|
|
552
|
+
render: (index) => getBananaFrame(index, columns, spriteRows),
|
|
553
|
+
frameMs: BANANA_FRAME_MS,
|
|
554
|
+
},
|
|
555
|
+
cat: {
|
|
556
|
+
render: (index) => getNyanFrame(index, columns),
|
|
557
|
+
frameMs: NYAN_FRAME_MS,
|
|
558
|
+
},
|
|
559
|
+
pacman: {
|
|
560
|
+
render: (index) => getPacmanFrame(index, columns, spriteRows),
|
|
561
|
+
frameMs: PACMAN_FRAME_MS,
|
|
562
|
+
},
|
|
563
|
+
upyachka: {
|
|
564
|
+
render: (index) => getUpyachkaFrame(index, columns, spriteRows),
|
|
565
|
+
frameMs: UPYACHKA_FRAME_MS,
|
|
566
|
+
},
|
|
567
|
+
}[context.sprite]
|
|
498
568
|
process.stderr.write('\x1b[?7l')
|
|
499
569
|
|
|
500
570
|
const render = () => {
|
|
501
|
-
|
|
571
|
+
const waiting = phase !== 'receiving'
|
|
572
|
+
const extra = waiting ? `Thinking… ${elapsedSeconds()}s` : ''
|
|
573
|
+
const logBlock = formatLogBlock(logLines, columns, logRows, extra)
|
|
574
|
+
logUpdateStderr(
|
|
575
|
+
`${sprite.render(frameIndex)}\n${formatText()}${logBlock ? `\n${logBlock}` : ''}`,
|
|
576
|
+
)
|
|
502
577
|
frameIndex++
|
|
503
578
|
}
|
|
504
579
|
|
|
505
|
-
const frameTimer = setInterval(render, frameMs)
|
|
580
|
+
const frameTimer = setInterval(render, sprite.frameMs)
|
|
506
581
|
const stepTimer = setInterval(() => {
|
|
507
582
|
if (phase === 'early') {
|
|
508
583
|
if (earlyStepIndex < earlySteps.length - 1) {
|
|
@@ -510,14 +585,14 @@ async function withAgentProgress(context, callback) {
|
|
|
510
585
|
} else {
|
|
511
586
|
phase = 'waiting'
|
|
512
587
|
}
|
|
513
|
-
} else if (phase
|
|
588
|
+
} else if (phase !== 'receiving') {
|
|
514
589
|
waitStepIndex++
|
|
515
590
|
}
|
|
516
591
|
}, AGENT_STEP_MS)
|
|
517
592
|
|
|
518
593
|
try {
|
|
519
594
|
render()
|
|
520
|
-
return await callback({ onAgentActivity })
|
|
595
|
+
return await callback({ onAgentActivity: reportActivity })
|
|
521
596
|
} finally {
|
|
522
597
|
clearInterval(frameTimer)
|
|
523
598
|
clearInterval(stepTimer)
|
|
@@ -526,6 +601,16 @@ async function withAgentProgress(context, callback) {
|
|
|
526
601
|
}
|
|
527
602
|
}
|
|
528
603
|
|
|
604
|
+
function formatLogBlock(lines, columns, logRows, extra = '') {
|
|
605
|
+
const all = extra ? [...lines, extra] : lines
|
|
606
|
+
if (!all.length) return ''
|
|
607
|
+
const width = Math.max(20, columns || 80)
|
|
608
|
+
return all
|
|
609
|
+
.slice(-logRows)
|
|
610
|
+
.map((line) => clipLog(line, width))
|
|
611
|
+
.join('\n')
|
|
612
|
+
}
|
|
613
|
+
|
|
529
614
|
function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
|
|
530
615
|
const cmd = resolveAgent()
|
|
531
616
|
const args = [
|
|
@@ -533,7 +618,8 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
|
|
|
533
618
|
'--mode',
|
|
534
619
|
'ask',
|
|
535
620
|
'--output-format',
|
|
536
|
-
'
|
|
621
|
+
'stream-json',
|
|
622
|
+
'--stream-partial-output',
|
|
537
623
|
'--trust',
|
|
538
624
|
'--workspace',
|
|
539
625
|
cwd,
|
|
@@ -544,6 +630,9 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
|
|
|
544
630
|
args.push(prompt)
|
|
545
631
|
|
|
546
632
|
if (verbose) process.stderr.write(`Using: ${cmd}\n`)
|
|
633
|
+
if (onAgentActivity) {
|
|
634
|
+
onAgentActivity({ log: `Sending prompt (${prompt.length} chars)` })
|
|
635
|
+
}
|
|
547
636
|
|
|
548
637
|
return new Promise((resolve, reject) => {
|
|
549
638
|
const child = spawn(cmd, args, {
|
|
@@ -551,44 +640,240 @@ function askAgent(cwd, model, prompt, verbose, onAgentActivity) {
|
|
|
551
640
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
552
641
|
})
|
|
553
642
|
|
|
554
|
-
|
|
643
|
+
const stream = {
|
|
644
|
+
buffer: '',
|
|
645
|
+
lastAssistant: '',
|
|
646
|
+
raw: '',
|
|
647
|
+
result: '',
|
|
648
|
+
thinking: '',
|
|
649
|
+
}
|
|
555
650
|
let stderr = ''
|
|
651
|
+
let settled = false
|
|
652
|
+
|
|
653
|
+
const stopChild = (signal = 'SIGTERM') => {
|
|
654
|
+
if (child.exitCode !== null || child.killed) return
|
|
655
|
+
child.kill(signal)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const finish = (error, value) => {
|
|
659
|
+
if (settled) return
|
|
660
|
+
settled = true
|
|
661
|
+
process.removeListener('SIGINT', onSignal)
|
|
662
|
+
process.removeListener('SIGTERM', onSignal)
|
|
663
|
+
if (error) reject(error)
|
|
664
|
+
else resolve(value)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const onSignal = () => {
|
|
668
|
+
stopChild('SIGTERM')
|
|
669
|
+
finish(new Error('Interrupted'))
|
|
670
|
+
}
|
|
671
|
+
process.once('SIGINT', onSignal)
|
|
672
|
+
process.once('SIGTERM', onSignal)
|
|
556
673
|
|
|
557
674
|
child.stdout.on('data', (chunk) => {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
675
|
+
const text = String(chunk)
|
|
676
|
+
stream.raw += text
|
|
677
|
+
const activities = consumeAgentStdout(stream, text)
|
|
678
|
+
for (const activity of activities) {
|
|
679
|
+
if (onAgentActivity) onAgentActivity(activity)
|
|
680
|
+
}
|
|
561
681
|
})
|
|
562
682
|
child.stderr.on('data', (chunk) => {
|
|
563
|
-
|
|
564
|
-
|
|
683
|
+
const text = String(chunk)
|
|
684
|
+
stderr += text
|
|
685
|
+
const line = lastNonEmptyLine(text)
|
|
686
|
+
if (line && onAgentActivity) onAgentActivity({ log: line })
|
|
687
|
+
if (verbose) process.stderr.write(text)
|
|
565
688
|
})
|
|
566
689
|
|
|
567
|
-
const timeout = setTimeout(() => {
|
|
568
|
-
child.kill('SIGTERM')
|
|
569
|
-
reject(
|
|
570
|
-
new Error(
|
|
571
|
-
`Cursor Agent timed out after ${AGENT_TIMEOUT_MS / 1000}s. Check agent login / network.`,
|
|
572
|
-
),
|
|
573
|
-
)
|
|
574
|
-
}, AGENT_TIMEOUT_MS)
|
|
575
|
-
|
|
576
690
|
child.on('error', (error) => {
|
|
577
|
-
|
|
578
|
-
reject(error)
|
|
691
|
+
finish(error)
|
|
579
692
|
})
|
|
580
693
|
|
|
581
694
|
child.on('close', (code) => {
|
|
582
|
-
|
|
695
|
+
consumeAgentStdout(stream, '\n')
|
|
696
|
+
const raw = (
|
|
697
|
+
stream.result ||
|
|
698
|
+
stream.lastAssistant ||
|
|
699
|
+
stream.buffer ||
|
|
700
|
+
stream.raw
|
|
701
|
+
).trim()
|
|
583
702
|
if (code) {
|
|
584
|
-
|
|
703
|
+
finish(new Error(formatAgentError(stderr, raw)))
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
if (!raw) {
|
|
707
|
+
finish(new Error(formatAgentError(stderr, '')))
|
|
585
708
|
return
|
|
586
709
|
}
|
|
587
|
-
|
|
710
|
+
finish(null, raw)
|
|
588
711
|
})
|
|
589
712
|
})
|
|
590
713
|
}
|
|
591
714
|
|
|
715
|
+
function consumeAgentStdout(stream, chunk) {
|
|
716
|
+
stream.buffer += chunk
|
|
717
|
+
const lines = stream.buffer.split('\n')
|
|
718
|
+
stream.buffer = lines.pop() || ''
|
|
719
|
+
const activities = []
|
|
720
|
+
for (const line of lines) {
|
|
721
|
+
const activity = applyAgentLine(stream, line)
|
|
722
|
+
if (activity) activities.push(activity)
|
|
723
|
+
}
|
|
724
|
+
return activities
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function applyAgentLine(stream, line) {
|
|
728
|
+
const trimmed = line.trim()
|
|
729
|
+
if (!trimmed) return null
|
|
730
|
+
|
|
731
|
+
let event
|
|
732
|
+
try {
|
|
733
|
+
event = JSON.parse(trimmed)
|
|
734
|
+
} catch {
|
|
735
|
+
if (!stream.result) stream.result = trimmed
|
|
736
|
+
return {
|
|
737
|
+
charCount: trimmed.length,
|
|
738
|
+
log: trimmed,
|
|
739
|
+
stream: true,
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!event || typeof event !== 'object') return null
|
|
744
|
+
if (event.type === 'user') return null
|
|
745
|
+
|
|
746
|
+
if (event.type === 'thinking') {
|
|
747
|
+
const text = extractThinkingText(event)
|
|
748
|
+
if (event.subtype === 'completed') {
|
|
749
|
+
return stream.thinking
|
|
750
|
+
? {
|
|
751
|
+
log: `Thinking: ${stream.thinking}`,
|
|
752
|
+
stream: true,
|
|
753
|
+
streamKey: 'thinking',
|
|
754
|
+
}
|
|
755
|
+
: { log: 'Finished thinking' }
|
|
756
|
+
}
|
|
757
|
+
if (text) {
|
|
758
|
+
stream.thinking += text
|
|
759
|
+
return {
|
|
760
|
+
log: `Thinking: ${stream.thinking}`,
|
|
761
|
+
stream: true,
|
|
762
|
+
streamKey: 'thinking',
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return { log: 'Thinking' }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (event.type === 'result' && typeof event.result === 'string') {
|
|
769
|
+
stream.result = event.result
|
|
770
|
+
return { charCount: stream.result.length, log: 'Agent finished' }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (event.type === 'assistant') {
|
|
774
|
+
if (event.model_call_id != null) return null
|
|
775
|
+
const thinking = extractContentText(event.message?.content, 'thinking')
|
|
776
|
+
if (thinking) {
|
|
777
|
+
stream.thinking += thinking
|
|
778
|
+
return {
|
|
779
|
+
log: `Thinking: ${stream.thinking}`,
|
|
780
|
+
stream: true,
|
|
781
|
+
streamKey: 'thinking',
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const text = extractAssistantText(event)
|
|
785
|
+
if (!text) return null
|
|
786
|
+
if (typeof event.timestamp_ms === 'number') stream.lastAssistant += text
|
|
787
|
+
else stream.lastAssistant = text
|
|
788
|
+
return {
|
|
789
|
+
charCount: stream.lastAssistant.length,
|
|
790
|
+
log: stream.lastAssistant,
|
|
791
|
+
stream: true,
|
|
792
|
+
streamKey: 'assistant',
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const log = describeAgentEvent(event)
|
|
797
|
+
if (log) return { log }
|
|
798
|
+
if (event.type) {
|
|
799
|
+
return {
|
|
800
|
+
log: event.subtype
|
|
801
|
+
? `${event.type}: ${event.subtype}`
|
|
802
|
+
: String(event.type),
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return null
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function extractAssistantText(event) {
|
|
809
|
+
return extractContentText(event.message?.content, 'text')
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function extractThinkingText(event) {
|
|
813
|
+
if (typeof event.text === 'string' && event.text) return event.text
|
|
814
|
+
if (typeof event.delta === 'string' && event.delta) return event.delta
|
|
815
|
+
return (
|
|
816
|
+
extractContentText(event.message?.content, 'thinking') ||
|
|
817
|
+
extractContentText(event.content, 'thinking') ||
|
|
818
|
+
extractContentText(event.message?.content, 'text')
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function extractContentText(content, type) {
|
|
823
|
+
if (!Array.isArray(content)) return ''
|
|
824
|
+
return content
|
|
825
|
+
.filter((part) => part?.type === type && typeof part.text === 'string')
|
|
826
|
+
.map((part) => part.text)
|
|
827
|
+
.join('')
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function describeAgentEvent(event) {
|
|
831
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
832
|
+
return event.model ? `Agent started (${event.model})` : 'Agent started'
|
|
833
|
+
}
|
|
834
|
+
if (event.type === 'tool_call') {
|
|
835
|
+
return describeToolCall(event.tool_call, event.subtype === 'completed')
|
|
836
|
+
}
|
|
837
|
+
return ''
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function describeToolCall(toolCall, done) {
|
|
841
|
+
if (!toolCall || typeof toolCall !== 'object') {
|
|
842
|
+
return done ? 'Tool done' : 'Tool call'
|
|
843
|
+
}
|
|
844
|
+
const [key, payload] = Object.entries(toolCall)[0] || []
|
|
845
|
+
if (key === 'function' && payload?.name) {
|
|
846
|
+
return done ? `Done ${payload.name}` : payload.name
|
|
847
|
+
}
|
|
848
|
+
const args =
|
|
849
|
+
payload?.args && typeof payload.args === 'object' ? payload.args : {}
|
|
850
|
+
const name = (key || 'tool').replace(/ToolCall$/, '')
|
|
851
|
+
const detail =
|
|
852
|
+
args.path ||
|
|
853
|
+
args.file_path ||
|
|
854
|
+
args.command ||
|
|
855
|
+
args.query ||
|
|
856
|
+
args.pattern ||
|
|
857
|
+
args.glob ||
|
|
858
|
+
''
|
|
859
|
+
const label = detail ? `${name} ${detail}` : name
|
|
860
|
+
return done ? `Done ${label}` : `${label}`
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function lastNonEmptyLine(text) {
|
|
864
|
+
const lines = String(text)
|
|
865
|
+
.split(/\r?\n/)
|
|
866
|
+
.map((line) => line.trim())
|
|
867
|
+
.filter(Boolean)
|
|
868
|
+
return lines.at(-1) || ''
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function clipLog(text, max = AGENT_LOG_MAX) {
|
|
872
|
+
const oneLine = String(text).replace(/\s+/g, ' ').trim()
|
|
873
|
+
if (oneLine.length <= max) return oneLine
|
|
874
|
+
return `…${oneLine.slice(-(max - 1))}`
|
|
875
|
+
}
|
|
876
|
+
|
|
592
877
|
function formatAgentError(stderr, stdout) {
|
|
593
878
|
const text = (stderr || stdout || AGENT_MISSING).trim()
|
|
594
879
|
if (/authentication required/i.test(text)) {
|
|
@@ -848,6 +1133,10 @@ async function validateAndNormalizePlans(cwd, plans) {
|
|
|
848
1133
|
}
|
|
849
1134
|
}
|
|
850
1135
|
|
|
1136
|
+
function isOnDisk(cwd, file) {
|
|
1137
|
+
return fs.existsSync(path.join(cwd, file))
|
|
1138
|
+
}
|
|
1139
|
+
|
|
851
1140
|
function isTracked(cwd, file) {
|
|
852
1141
|
return (
|
|
853
1142
|
spawnSync('git', ['ls-files', '--error-unmatch', '--', file], {
|
|
@@ -857,16 +1146,25 @@ function isTracked(cwd, file) {
|
|
|
857
1146
|
)
|
|
858
1147
|
}
|
|
859
1148
|
|
|
1149
|
+
function isInHead(cwd, file) {
|
|
1150
|
+
return (
|
|
1151
|
+
spawnSync('git', ['cat-file', '-e', `HEAD:${file}`], {
|
|
1152
|
+
cwd: cwd,
|
|
1153
|
+
encoding: 'utf8',
|
|
1154
|
+
}).status === 0
|
|
1155
|
+
)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
860
1158
|
function isUntracked(cwd, file) {
|
|
861
|
-
return !isTracked(cwd, file) &&
|
|
1159
|
+
return !isTracked(cwd, file) && isOnDisk(cwd, file)
|
|
862
1160
|
}
|
|
863
1161
|
|
|
864
1162
|
function fileHasChanges(cwd, file) {
|
|
865
|
-
const
|
|
1163
|
+
const exists = isOnDisk(cwd, file)
|
|
866
1164
|
const tracked = isTracked(cwd, file)
|
|
867
1165
|
|
|
868
|
-
if (!tracked) return
|
|
869
|
-
if (!
|
|
1166
|
+
if (!exists && (tracked || isInHead(cwd, file))) return true
|
|
1167
|
+
if (!tracked) return exists
|
|
870
1168
|
|
|
871
1169
|
return (
|
|
872
1170
|
spawnSync('git', ['diff', '--quiet', 'HEAD', '--', file], {
|
|
@@ -878,7 +1176,20 @@ function fileHasChanges(cwd, file) {
|
|
|
878
1176
|
|
|
879
1177
|
function stageFiles(cwd, files) {
|
|
880
1178
|
if (!files.length) return
|
|
881
|
-
|
|
1179
|
+
|
|
1180
|
+
const existing = []
|
|
1181
|
+
const removed = []
|
|
1182
|
+
for (const file of files) {
|
|
1183
|
+
if (isOnDisk(cwd, file)) existing.push(file)
|
|
1184
|
+
else removed.push(file)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// `git add -A -- deleted/path` warns or misses the deletion when the
|
|
1188
|
+
// parent directory is already gone. `git rm` stages the index removal.
|
|
1189
|
+
if (existing.length) git(cwd, ['add', '-A', '--', ...existing])
|
|
1190
|
+
if (removed.length) {
|
|
1191
|
+
git(cwd, ['rm', '--ignore-unmatch', '-q', '--', ...removed])
|
|
1192
|
+
}
|
|
882
1193
|
}
|
|
883
1194
|
|
|
884
1195
|
async function confirm(count) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// High-resolution terminal sprites reconstructed from the supplied reference.
|
|
2
|
+
// Two vertical source pixels share one terminal cell via half-block glyphs.
|
|
3
|
+
|
|
4
|
+
const RESET = '\x1b[0m'
|
|
5
|
+
const FRAME_WIDTH = 80
|
|
6
|
+
const FRAME_HEIGHT = 16
|
|
7
|
+
const FRAME_COUNT = 82
|
|
8
|
+
const SPRITE_WIDTH = 14
|
|
9
|
+
const SPRITE_HEIGHT = 16
|
|
10
|
+
const COLORS = { B: 33, C: 51, O: 208, P: 218, R: 196, W: 231, Y: 226 }
|
|
11
|
+
|
|
12
|
+
const GHOST = [
|
|
13
|
+
'................',
|
|
14
|
+
'......XXXX......',
|
|
15
|
+
'....XXXXXXXX....',
|
|
16
|
+
'...XXXXXXXXXX...',
|
|
17
|
+
'..XXXXXXXXXXXX..',
|
|
18
|
+
'..XWWXXXXWWWXXX.',
|
|
19
|
+
'..WWWWXXWWWWXX..',
|
|
20
|
+
'..BBWWXXBBWWXX..',
|
|
21
|
+
'.XBBWWXXBBWWXXX.',
|
|
22
|
+
'.XXWWXXXXWWXXXX.',
|
|
23
|
+
'.XXXXXXXXXXXXXX.',
|
|
24
|
+
'.XXXXXXXXXXXXXX.',
|
|
25
|
+
'.XXXXXXXXXXXXXX.',
|
|
26
|
+
'.XXXXXXXXXXXXXX.',
|
|
27
|
+
'.XXXXXXXXXXXXXX.',
|
|
28
|
+
'.XX.XXX..XXX.XX.',
|
|
29
|
+
'.X...XX..XX...X.',
|
|
30
|
+
'................',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const PACMAN_CLOSED = [
|
|
34
|
+
'................',
|
|
35
|
+
'.....YYYYYY.....',
|
|
36
|
+
'...YYYYYYYYYY...',
|
|
37
|
+
'..YYYYYYYYYYYY..',
|
|
38
|
+
'.YYYYYYYYYYYYYY.',
|
|
39
|
+
'YYYYYYYYYYYYYYYY',
|
|
40
|
+
'YYYYYYYYYYYYYYYY',
|
|
41
|
+
'YYYYYYYYYYYYYYYY',
|
|
42
|
+
'YYYYYYYYYYYYYYYY',
|
|
43
|
+
'YYYYYYYYYYYYYYYY',
|
|
44
|
+
'YYYYYYYYYYYYYYYY',
|
|
45
|
+
'YYYYYYYYYYYYYYYY',
|
|
46
|
+
'YYYYYYYYYYYYYYYY',
|
|
47
|
+
'.YYYYYYYYYYYYYY.',
|
|
48
|
+
'..YYYYYYYYYYYY..',
|
|
49
|
+
'...YYYYYYYYYY...',
|
|
50
|
+
'.....YYYYYY.....',
|
|
51
|
+
'................',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const PACMAN_OPEN = [
|
|
55
|
+
'................',
|
|
56
|
+
'.....YYYYYY.....',
|
|
57
|
+
'...YYYYYYYYYY...',
|
|
58
|
+
'..YYYYYYYYYYYY..',
|
|
59
|
+
'.YYYYYYYYYYYYYY.',
|
|
60
|
+
'YYYYYYYYYYYYYYYY',
|
|
61
|
+
'..YYYYYYYYYYYYYY',
|
|
62
|
+
'....YYYYYYYYYYYY',
|
|
63
|
+
'......YYYYYYYYYY',
|
|
64
|
+
'........YYYYYYYY',
|
|
65
|
+
'......YYYYYYYYYY',
|
|
66
|
+
'....YYYYYYYYYYYY',
|
|
67
|
+
'..YYYYYYYYYYYYYY',
|
|
68
|
+
'.YYYYYYYYYYYYYY.',
|
|
69
|
+
'..YYYYYYYYYYYY..',
|
|
70
|
+
'...YYYYYYYYYY...',
|
|
71
|
+
'.....YYYYYY.....',
|
|
72
|
+
'................',
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
function drawSprite(canvas, sprite, left, color) {
|
|
76
|
+
const columns = pickIndexes(sprite[0].length, SPRITE_WIDTH)
|
|
77
|
+
const rows = pickIndexes(sprite.length, SPRITE_HEIGHT)
|
|
78
|
+
for (let y = 0; y < rows.length; y++) {
|
|
79
|
+
for (let x = 0; x < columns.length; x++) {
|
|
80
|
+
const cell = sprite[rows[y]][columns[x]]
|
|
81
|
+
const targetX = left + x
|
|
82
|
+
if (cell === '.' || targetX < 0 || targetX >= FRAME_WIDTH) continue
|
|
83
|
+
canvas[y][targetX] = cell === 'X' ? color : cell
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildFrame(index) {
|
|
89
|
+
const canvas = Array.from({ length: FRAME_HEIGHT }, () =>
|
|
90
|
+
Array(FRAME_WIDTH).fill('.'),
|
|
91
|
+
)
|
|
92
|
+
const pacmanLeft = FRAME_WIDTH - 7 - index * 2
|
|
93
|
+
const pacman = Math.floor(index / 2) % 2 ? PACMAN_CLOSED : PACMAN_OPEN
|
|
94
|
+
drawSprite(canvas, pacman, pacmanLeft, 'Y')
|
|
95
|
+
|
|
96
|
+
for (const [ghostIndex, color] of ['R', 'C', 'P', 'O'].entries()) {
|
|
97
|
+
drawSprite(canvas, GHOST, pacmanLeft + 20 + ghostIndex * 18, color)
|
|
98
|
+
}
|
|
99
|
+
return canvas
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function pickIndexes(srcSize, maxSize) {
|
|
103
|
+
if (srcSize <= maxSize) {
|
|
104
|
+
return Array.from({ length: srcSize }, (_, index) => index)
|
|
105
|
+
}
|
|
106
|
+
return Array.from({ length: maxSize }, (_, index) =>
|
|
107
|
+
Math.round((index * (srcSize - 1)) / (maxSize - 1)),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function paint(top, bottom) {
|
|
112
|
+
const topColor = COLORS[top]
|
|
113
|
+
const bottomColor = COLORS[bottom]
|
|
114
|
+
if (topColor === undefined && bottomColor === undefined) return ' '
|
|
115
|
+
if (topColor === bottomColor) return `\x1b[48;5;${topColor}m \x1b[0m`
|
|
116
|
+
if (topColor === undefined) return `\x1b[38;5;${bottomColor}m▄\x1b[0m`
|
|
117
|
+
if (bottomColor === undefined) return `\x1b[38;5;${topColor}m▀\x1b[0m`
|
|
118
|
+
return `\x1b[38;5;${topColor};48;5;${bottomColor}m▀\x1b[0m`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getPacmanFrame(index, terminalColumns = 80, terminalRows = 24) {
|
|
122
|
+
const frame = buildFrame(index % FRAME_COUNT)
|
|
123
|
+
const maxColumns = Math.max(16, (terminalColumns || 80) - 1)
|
|
124
|
+
const maxLogicalRows = Math.max(4, ((terminalRows || 24) - 2) * 2)
|
|
125
|
+
const columns = pickIndexes(FRAME_WIDTH, maxColumns)
|
|
126
|
+
const rowCount = Math.min(FRAME_HEIGHT, maxLogicalRows)
|
|
127
|
+
const rows = pickIndexes(FRAME_HEIGHT, rowCount - (rowCount % 2))
|
|
128
|
+
const lines = []
|
|
129
|
+
|
|
130
|
+
for (let index = 0; index < rows.length; index += 2) {
|
|
131
|
+
const top = frame[rows[index]]
|
|
132
|
+
const bottom = frame[rows[index + 1]]
|
|
133
|
+
lines.push(columns.map((x) => paint(top[x], bottom[x])).join('') + RESET)
|
|
134
|
+
}
|
|
135
|
+
return lines.join('\n')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getPacmanFrameCount() {
|
|
139
|
+
return FRAME_COUNT
|
|
140
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Five frames extracted from the classic upyachka GIF.
|
|
2
|
+
// Default-color block pixels stay visible on light and dark terminals.
|
|
3
|
+
|
|
4
|
+
const FRAME_WIDTH = 29
|
|
5
|
+
const FRAME_HEIGHT = 36
|
|
6
|
+
|
|
7
|
+
const FRAMES = [
|
|
8
|
+
[
|
|
9
|
+
'..................#..........',
|
|
10
|
+
'................#####........',
|
|
11
|
+
'...............#######.......',
|
|
12
|
+
'............#..##....##......',
|
|
13
|
+
'............#####....##......',
|
|
14
|
+
'...........######....##......',
|
|
15
|
+
'...........##.####.###.......',
|
|
16
|
+
'...........##...#####........',
|
|
17
|
+
'...........##....###.........',
|
|
18
|
+
'...........##....###.........',
|
|
19
|
+
'...........##...#####........',
|
|
20
|
+
'...........#....##.##........',
|
|
21
|
+
'.............#####..#........',
|
|
22
|
+
'..............########.......',
|
|
23
|
+
'...............##.####.......',
|
|
24
|
+
'..............##.............',
|
|
25
|
+
'..............##.............',
|
|
26
|
+
'.............######..........',
|
|
27
|
+
'.............##########......',
|
|
28
|
+
'.............##....#####.....',
|
|
29
|
+
'.............##.......##.....',
|
|
30
|
+
'.............##.......##.....',
|
|
31
|
+
'.............##......##......',
|
|
32
|
+
'.............##......##......',
|
|
33
|
+
'.............##......##......',
|
|
34
|
+
'.............##......##......',
|
|
35
|
+
'.............##.....##.......',
|
|
36
|
+
'.............##.....##.......',
|
|
37
|
+
'.............##.....##.......',
|
|
38
|
+
'.............##..............',
|
|
39
|
+
'.............##..............',
|
|
40
|
+
'.............##..............',
|
|
41
|
+
'.............##..............',
|
|
42
|
+
'.............##..............',
|
|
43
|
+
'.............##..............',
|
|
44
|
+
'..............#..............',
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
'...............##............',
|
|
48
|
+
'.............######..........',
|
|
49
|
+
'............###..###.........',
|
|
50
|
+
'............##....##.##......',
|
|
51
|
+
'............##....#####......',
|
|
52
|
+
'............##....######.....',
|
|
53
|
+
'.............#######..##.....',
|
|
54
|
+
'..............#####...##.....',
|
|
55
|
+
'...............###....##.....',
|
|
56
|
+
'...............###....##.....',
|
|
57
|
+
'..............##.##...##.....',
|
|
58
|
+
'..............##.##....#.....',
|
|
59
|
+
'.............##.#####........',
|
|
60
|
+
'.............########........',
|
|
61
|
+
'.............####.##.........',
|
|
62
|
+
'...................##........',
|
|
63
|
+
'...................##........',
|
|
64
|
+
'...............#######.......',
|
|
65
|
+
'............##########.......',
|
|
66
|
+
'...........#####....##.......',
|
|
67
|
+
'...........##.......##.......',
|
|
68
|
+
'............##......##.......',
|
|
69
|
+
'............##......##.......',
|
|
70
|
+
'............##......##.......',
|
|
71
|
+
'............##......##.......',
|
|
72
|
+
'............##......##.......',
|
|
73
|
+
'.............##.....##.......',
|
|
74
|
+
'.............##.....##.......',
|
|
75
|
+
'.............##.....##.......',
|
|
76
|
+
'....................##.......',
|
|
77
|
+
'....................##.......',
|
|
78
|
+
'....................##.......',
|
|
79
|
+
'....................##.......',
|
|
80
|
+
'....................##.......',
|
|
81
|
+
'....................##.......',
|
|
82
|
+
'....................#........',
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
'.............................',
|
|
86
|
+
'.............................',
|
|
87
|
+
'.............................',
|
|
88
|
+
'.............................',
|
|
89
|
+
'.............................',
|
|
90
|
+
'.............................',
|
|
91
|
+
'..............#..............',
|
|
92
|
+
'............#####............',
|
|
93
|
+
'...........###.###...........',
|
|
94
|
+
'...........##...##...........',
|
|
95
|
+
'..........##.....#...........',
|
|
96
|
+
'...........##...##...........',
|
|
97
|
+
'.......###############.......',
|
|
98
|
+
'.......###############.......',
|
|
99
|
+
'.......##....###....##.......',
|
|
100
|
+
'........##....#.....##.......',
|
|
101
|
+
'........##....##....#........',
|
|
102
|
+
'.........##...##...##........',
|
|
103
|
+
'.........##...##...##........',
|
|
104
|
+
'..........#...##...##........',
|
|
105
|
+
'..............##.............',
|
|
106
|
+
'..............##.............',
|
|
107
|
+
'..............##.............',
|
|
108
|
+
'..............##.............',
|
|
109
|
+
'..........###########........',
|
|
110
|
+
'......#######...########.....',
|
|
111
|
+
'.....#####..........####.....',
|
|
112
|
+
'.....##...............##.....',
|
|
113
|
+
'......##..............##.....',
|
|
114
|
+
'......##.............##......',
|
|
115
|
+
'......##.............##......',
|
|
116
|
+
'......##.............##......',
|
|
117
|
+
'.......##...........##.......',
|
|
118
|
+
'.......##...........##.......',
|
|
119
|
+
'.......##...........##.......',
|
|
120
|
+
'.......##....................',
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
'.............................',
|
|
124
|
+
'............#####............',
|
|
125
|
+
'...........#######...........',
|
|
126
|
+
'###.......###...##...........',
|
|
127
|
+
'.####.....##....##.......####',
|
|
128
|
+
'...####...##....##....######.',
|
|
129
|
+
'.....########..##########....',
|
|
130
|
+
'.......###############.......',
|
|
131
|
+
'............#####............',
|
|
132
|
+
'.............##..............',
|
|
133
|
+
'.............##..............',
|
|
134
|
+
'..............#..............',
|
|
135
|
+
'..............#..............',
|
|
136
|
+
'..............#..............',
|
|
137
|
+
'..............#..............',
|
|
138
|
+
'..............##.............',
|
|
139
|
+
'..............##.............',
|
|
140
|
+
'..............##.............',
|
|
141
|
+
'..............##.............',
|
|
142
|
+
'.............###.............',
|
|
143
|
+
'............#####............',
|
|
144
|
+
'............##.###...........',
|
|
145
|
+
'...........##...##...........',
|
|
146
|
+
'...........##....##..........',
|
|
147
|
+
'..........##.....##..........',
|
|
148
|
+
'.........##.......##.........',
|
|
149
|
+
'.........##.......##.........',
|
|
150
|
+
'.........##........##........',
|
|
151
|
+
'........###........##........',
|
|
152
|
+
'........##.........##........',
|
|
153
|
+
'........##.........##........',
|
|
154
|
+
'........##.........##........',
|
|
155
|
+
'.......##...........##.......',
|
|
156
|
+
'.......##...........##.......',
|
|
157
|
+
'.......##...........##.......',
|
|
158
|
+
'.............................',
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
'.............................',
|
|
162
|
+
'.............................',
|
|
163
|
+
'.............................',
|
|
164
|
+
'.............................',
|
|
165
|
+
'.............................',
|
|
166
|
+
'.............................',
|
|
167
|
+
'.............................',
|
|
168
|
+
'.............................',
|
|
169
|
+
'.............................',
|
|
170
|
+
'.............................',
|
|
171
|
+
'.............................',
|
|
172
|
+
'............###..............',
|
|
173
|
+
'...........######............',
|
|
174
|
+
'..........###..###...........',
|
|
175
|
+
'..........##....##...........',
|
|
176
|
+
'..........##....##...........',
|
|
177
|
+
'..........##...###...........',
|
|
178
|
+
'...........######............',
|
|
179
|
+
'............####.............',
|
|
180
|
+
'...........#####.............',
|
|
181
|
+
'..........#######............',
|
|
182
|
+
'.........#########...........',
|
|
183
|
+
'........##########...........',
|
|
184
|
+
'........###..######..........',
|
|
185
|
+
'.............##..##..........',
|
|
186
|
+
'.....###.....##......###.....',
|
|
187
|
+
'.....######..##...#####......',
|
|
188
|
+
'......#################......',
|
|
189
|
+
'......##...#######...##......',
|
|
190
|
+
'......##.....##.....##.......',
|
|
191
|
+
'.......##...........##.......',
|
|
192
|
+
'.......##..........##........',
|
|
193
|
+
'.......###.........##........',
|
|
194
|
+
'........##.........##........',
|
|
195
|
+
'........##.........#.........',
|
|
196
|
+
'.............................',
|
|
197
|
+
],
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
function pickIndexes(srcSize, maxSize) {
|
|
201
|
+
if (srcSize <= maxSize) {
|
|
202
|
+
return Array.from({ length: srcSize }, (_, index) => index)
|
|
203
|
+
}
|
|
204
|
+
return Array.from({ length: maxSize }, (_, index) =>
|
|
205
|
+
Math.round((index * (srcSize - 1)) / (maxSize - 1)),
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getUpyachkaFrame(
|
|
210
|
+
index,
|
|
211
|
+
terminalColumns = 80,
|
|
212
|
+
terminalRows = 24,
|
|
213
|
+
) {
|
|
214
|
+
const maxColumns = Math.max(8, Math.floor((terminalColumns - 1) / 2))
|
|
215
|
+
const maxRows = Math.max(8, (terminalRows || 24) - 2)
|
|
216
|
+
const columns = pickIndexes(FRAME_WIDTH, maxColumns)
|
|
217
|
+
const rows = pickIndexes(FRAME_HEIGHT, maxRows)
|
|
218
|
+
const frame = FRAMES[index % FRAMES.length]
|
|
219
|
+
|
|
220
|
+
return rows
|
|
221
|
+
.map((y) =>
|
|
222
|
+
columns.map((x) => (frame[y][x] === '#' ? '██' : ' ')).join(''),
|
|
223
|
+
)
|
|
224
|
+
.join('\n')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function getUpyachkaFrameCount() {
|
|
228
|
+
return FRAMES.length
|
|
229
|
+
}
|