zames_pro 2.74.0 → 2.75.1
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/CHANGELOG.md +38 -0
- package/README.md +24 -0
- package/dist/attachments.js +6 -1
- package/dist/commands.js +0 -1
- package/dist/config.js +7 -7
- package/dist/i18n/common.js +0 -8
- package/dist/i18n/config.js +0 -1
- package/dist/i18n/help.js +8 -4
- package/dist/i18n/qwen.js +0 -4
- package/dist/i18n/self-review.js +2 -18
- package/dist/i18n/spinner.js +5 -0
- package/dist/index.js +30 -43
- package/dist/input/suggest.js +19 -4
- package/dist/theme.js +9 -0
- package/dist/tui/ConfigMenu.js +44 -11
- package/dist/tui/FullScreenApp.js +44 -11
- package/dist/tui/InputLine.js +52 -19
- package/dist/tui/SelectList.js +6 -2
- package/dist/tui/StatusBar.js +66 -36
- package/dist/tui/TuiApp.js +3 -2
- package/dist/tui/TuiEditor.js +44 -4
- package/dist/tui/edit.js +21 -1
- package/dist/tui/keys.js +46 -2
- package/dist/tui/store.js +1 -1
- package/dist/tui/useMouseWheel.js +80 -29
- package/dist/tui/widgets.js +6 -1
- package/package.json +2 -1
- package/scripts/ctd-live-driver.mts +51 -0
- package/scripts/ctd-live.tsx +33 -0
- package/scripts/escd-live-driver.mts +37 -0
- package/scripts/inline-status-driver.mts +31 -0
- package/scripts/inline-status.tsx +33 -0
- package/scripts/mouse-churn.mts +34 -0
- package/scripts/qwen-models-smoke.mts +117 -0
- package/scripts/ru-shot.mts +20 -0
- package/scripts/tui-comp-shot.mts +63 -0
- package/scripts/tui-components.tsx +62 -0
- package/scripts/tui-config-driver.mts +82 -0
- package/scripts/tui-config-probe.tsx +74 -0
- package/scripts/tui-edit-driver.mts +100 -0
- package/scripts/tui-edit-probe.tsx +38 -0
- package/scripts/tui-editor-probe.tsx +24 -0
- package/scripts/tui-input-driver.mts +54 -0
- package/scripts/tui-input-shot.tsx +59 -0
- package/scripts/tui-log-driver.mts +35 -0
- package/scripts/tui-log-shot.tsx +48 -0
- package/scripts/tui-longpaste-driver.mts +56 -0
- package/scripts/tui-longpaste.tsx +35 -0
- package/scripts/tui-manyselect-shot.mts +34 -0
- package/scripts/tui-manyselect.tsx +17 -0
- package/scripts/tui-mouse-churn-probe.tsx +28 -0
- package/scripts/tui-paste-driver.mts +88 -0
- package/scripts/tui-paste-kinds.tsx +76 -0
- package/scripts/tui-pastetest.mts +38 -0
- package/scripts/tui-repro.tsx +64 -0
- package/scripts/tui-resize.mts +70 -0
- package/scripts/tui-scroll-driver.mts +53 -0
- package/scripts/tui-scroll-probe.tsx +46 -0
- package/scripts/tui-shot.mts +106 -0
- package/scripts/tui-status-driver.mts +24 -0
- package/scripts/tui-status-shot.tsx +27 -0
- package/scripts/tui-submit.tsx +57 -0
- package/scripts/tui-wheel-driver.mts +61 -0
- package/scripts/tui-wheel-probe.tsx +48 -0
- package/scripts/tui-widgets-driver.mts +20 -0
- package/scripts/tui-widgets.tsx +44 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { Term } from '../test/vt100.mts'
|
|
4
|
+
const NL = String.fromCharCode(10)
|
|
5
|
+
const ESC = String.fromCharCode(27)
|
|
6
|
+
const keys = (process.argv[2] || '')
|
|
7
|
+
.split(',')
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.map((k) => (k === 'UP' ? ESC + '[A' : k === 'DOWN' ? ESC + '[B' : k))
|
|
10
|
+
const term = new Term(20, 60)
|
|
11
|
+
const child = spawn(
|
|
12
|
+
'script',
|
|
13
|
+
['-qec', 'npx tsx scripts/tui-manyselect.tsx', '/dev/null'],
|
|
14
|
+
{
|
|
15
|
+
cwd: path.resolve('.'),
|
|
16
|
+
env: { ...process.env, COLUMNS: '60', LINES: '20' },
|
|
17
|
+
},
|
|
18
|
+
)
|
|
19
|
+
child.stdout.on('data', (d) => term.write(d.toString('utf-8')))
|
|
20
|
+
child.stderr.on('data', (d) => term.write(d.toString('utf-8')))
|
|
21
|
+
let i = 0
|
|
22
|
+
const tick = (): void => {
|
|
23
|
+
if (i < keys.length) {
|
|
24
|
+
child.stdin.write(keys[i])
|
|
25
|
+
i++
|
|
26
|
+
setTimeout(tick, 250)
|
|
27
|
+
} else
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
process.stdout.write(term.render() + NL)
|
|
30
|
+
child.kill('SIGTERM')
|
|
31
|
+
}, 400)
|
|
32
|
+
}
|
|
33
|
+
setTimeout(tick, 4500)
|
|
34
|
+
child.on('close', () => process.exit(0))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { render } from 'ink'
|
|
3
|
+
import { SelectList } from '../src/tui/SelectList.js'
|
|
4
|
+
const items = Array.from({ length: 30 }, (_, i) => ({
|
|
5
|
+
value: 'm' + i,
|
|
6
|
+
label: 'модель-' + i,
|
|
7
|
+
}))
|
|
8
|
+
render(
|
|
9
|
+
React.createElement(SelectList, {
|
|
10
|
+
title: 'Много',
|
|
11
|
+
items,
|
|
12
|
+
initial: 'm15',
|
|
13
|
+
onSelect: () => {},
|
|
14
|
+
}),
|
|
15
|
+
{ exitOnCtrlC: false, patchConsole: false },
|
|
16
|
+
)
|
|
17
|
+
setInterval(() => {}, 1 << 30)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Mount the REAL TuiEditor and tick the store every 150ms (like the 1s badge
|
|
2
|
+
// timer does), so the raw stdout can be scanned for how many times the
|
|
3
|
+
// mouse-mode sequences are written. A well-behaved hook writes them ONCE on
|
|
4
|
+
// mount; a hook whose effect depends on an inline `onWheel` re-runs on every
|
|
5
|
+
// render and writes `1007h` (alternate-scroll ON => wheel becomes history)
|
|
6
|
+
// again and again.
|
|
7
|
+
|
|
8
|
+
import { TuiEditor } from '../src/tui/TuiEditor.js'
|
|
9
|
+
|
|
10
|
+
const ed = new TuiEditor({
|
|
11
|
+
prompt: '> ',
|
|
12
|
+
commands: [],
|
|
13
|
+
locale: 'en',
|
|
14
|
+
alternateScreen: true,
|
|
15
|
+
})
|
|
16
|
+
ed.onSubmit = () => {}
|
|
17
|
+
ed.onAttach = () => null
|
|
18
|
+
ed.onClipboard = () => Promise.resolve(null)
|
|
19
|
+
ed.start()
|
|
20
|
+
|
|
21
|
+
let n = 0
|
|
22
|
+
const timer = setInterval(() => {
|
|
23
|
+
// A no-op-ish store change: bump the queue badge, like refreshBadges does.
|
|
24
|
+
ed.store.set({ queueLength: n++ })
|
|
25
|
+
if (n > 20) clearInterval(timer)
|
|
26
|
+
}, 150)
|
|
27
|
+
|
|
28
|
+
setInterval(() => {}, 1 << 30)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Feed a scripted set of bracketed pastes + Enter into the REAL TuiEditor
|
|
2
|
+
// probe under a pty, then print /tmp/paste-kinds.log (what onSubmit received).
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const ESC = String.fromCharCode(27)
|
|
8
|
+
const CR = String.fromCharCode(13)
|
|
9
|
+
const LOG = '/tmp/paste-kinds.log'
|
|
10
|
+
const TMP = '/tmp/paste-probe-tmp'
|
|
11
|
+
for (const f of [LOG]) {
|
|
12
|
+
try {
|
|
13
|
+
fs.unlinkSync(f)
|
|
14
|
+
} catch {
|
|
15
|
+
/* ignore */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
fs.mkdirSync(TMP, { recursive: true })
|
|
19
|
+
|
|
20
|
+
const png =
|
|
21
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC'
|
|
22
|
+
const dataUrl = 'data:image/png;base64,' + png
|
|
23
|
+
const existingFile = path.resolve('README.md')
|
|
24
|
+
|
|
25
|
+
const pastes = [
|
|
26
|
+
'hello plain text',
|
|
27
|
+
'line1' +
|
|
28
|
+
String.fromCharCode(10) +
|
|
29
|
+
'line2' +
|
|
30
|
+
String.fromCharCode(10) +
|
|
31
|
+
'line3' +
|
|
32
|
+
String.fromCharCode(10) +
|
|
33
|
+
'line4',
|
|
34
|
+
existingFile,
|
|
35
|
+
dataUrl,
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const child = spawn(
|
|
39
|
+
'script',
|
|
40
|
+
['-qec', 'npx tsx scripts/tui-paste-kinds.tsx', '/dev/null'],
|
|
41
|
+
{
|
|
42
|
+
cwd: path.resolve('.'),
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
COLUMNS: '90',
|
|
46
|
+
LINES: '28',
|
|
47
|
+
PROBE_PASTE_LOG: LOG,
|
|
48
|
+
PROBE_PASTE_TMP: TMP,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
child.stdout.on('data', () => {})
|
|
53
|
+
child.stderr.on('data', () => {})
|
|
54
|
+
|
|
55
|
+
const enc = (s: string): string => ESC + '[200~' + s + ESC + '[201~'
|
|
56
|
+
let i = 0
|
|
57
|
+
const step = (): void => {
|
|
58
|
+
if (i < pastes.length) {
|
|
59
|
+
child.stdin.write(enc(pastes[i]))
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
child.stdin.write(CR)
|
|
62
|
+
i++
|
|
63
|
+
setTimeout(step, 500)
|
|
64
|
+
}, 500)
|
|
65
|
+
} else {
|
|
66
|
+
setTimeout(() => child.kill('SIGTERM'), 600)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
setTimeout(step, 5000)
|
|
70
|
+
child.on('close', () => {
|
|
71
|
+
let out = ''
|
|
72
|
+
try {
|
|
73
|
+
out = fs.readFileSync(LOG, 'utf-8')
|
|
74
|
+
} catch {
|
|
75
|
+
out = '(no log)'
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write(out + String.fromCharCode(10))
|
|
78
|
+
try {
|
|
79
|
+
process.stdout.write('--- tmp files ---' + String.fromCharCode(10))
|
|
80
|
+
process.stdout.write(
|
|
81
|
+
fs.readdirSync(TMP).join(String.fromCharCode(10)) +
|
|
82
|
+
String.fromCharCode(10),
|
|
83
|
+
)
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
process.exit(0)
|
|
88
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Drive the REAL TuiEditor under a pty and log every onSubmit (text +
|
|
2
|
+
// attachments) so all paste kinds can be exercised: plain text, a big
|
|
3
|
+
// multi-line paste, a file path, and a data: URL image. The attach/clipboard
|
|
4
|
+
// callbacks mirror index.ts (parseImagePaste -> saveToTemp; resolveAttachPath
|
|
5
|
+
// -> readFile) so the probe exercises the same code path the live agent does.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import fsp from 'node:fs/promises'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { TuiEditor } from '../src/tui/TuiEditor.js'
|
|
11
|
+
import {
|
|
12
|
+
contentHash,
|
|
13
|
+
extForMime,
|
|
14
|
+
guessMime,
|
|
15
|
+
parseImagePaste,
|
|
16
|
+
saveToTemp,
|
|
17
|
+
} from '../src/attachments.js'
|
|
18
|
+
import { resolveAttachPath } from '../src/attach-refs.js'
|
|
19
|
+
|
|
20
|
+
const LOG = process.env.PROBE_PASTE_LOG || '/tmp/paste-kinds.log'
|
|
21
|
+
const TMP = process.env.PROBE_PASTE_TMP || '/tmp/paste-probe-tmp'
|
|
22
|
+
const WORKDIR = process.cwd()
|
|
23
|
+
const line = (s: string): void => {
|
|
24
|
+
fs.appendFileSync(LOG, s + String.fromCharCode(10))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ed = new TuiEditor({
|
|
28
|
+
prompt: '> ',
|
|
29
|
+
commands: [],
|
|
30
|
+
locale: 'en',
|
|
31
|
+
alternateScreen: true,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
ed.onSubmit = (text, atts) => {
|
|
35
|
+
line(
|
|
36
|
+
'SUBMIT text=' +
|
|
37
|
+
JSON.stringify(text) +
|
|
38
|
+
' atts=' +
|
|
39
|
+
JSON.stringify(
|
|
40
|
+
atts.map((a) => ({ marker: a.marker, image: a.image, name: a.name })),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
ed.onAttach = async (raw: string) => {
|
|
45
|
+
const image = parseImagePaste(raw)
|
|
46
|
+
if (image) {
|
|
47
|
+
const name = 'paste' + extForMime(image.mime)
|
|
48
|
+
const p = await saveToTemp(TMP, name, image.data)
|
|
49
|
+
const att = ed.attachments.add({
|
|
50
|
+
path: p,
|
|
51
|
+
name,
|
|
52
|
+
mime: image.mime,
|
|
53
|
+
size: image.data.length,
|
|
54
|
+
hash: contentHash(image.data),
|
|
55
|
+
})
|
|
56
|
+
line('ATTACH_IMAGE ' + att.marker + ' ' + p)
|
|
57
|
+
return att
|
|
58
|
+
}
|
|
59
|
+
const filePath = await resolveAttachPath(WORKDIR, raw)
|
|
60
|
+
if (!filePath) return null
|
|
61
|
+
const data = await fsp.readFile(filePath).catch(() => null)
|
|
62
|
+
if (!data) return null
|
|
63
|
+
const name = path.basename(filePath)
|
|
64
|
+
const att = ed.attachments.add({
|
|
65
|
+
path: filePath,
|
|
66
|
+
name,
|
|
67
|
+
mime: guessMime(name),
|
|
68
|
+
size: data.length,
|
|
69
|
+
})
|
|
70
|
+
line('ATTACH_FILE ' + att.marker + ' ' + filePath)
|
|
71
|
+
return att
|
|
72
|
+
}
|
|
73
|
+
ed.onClipboard = () => Promise.resolve(null)
|
|
74
|
+
|
|
75
|
+
ed.start()
|
|
76
|
+
setInterval(() => {}, 1 << 30)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Reproduce the [Pasted lines#N] path end to end under a pty: send ONE
|
|
2
|
+
// bracketed-paste chunk with several lines, press Enter, and print what the
|
|
3
|
+
// onSubmit callback actually received (that is what reaches the agent).
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
|
|
8
|
+
const ESC = String.fromCharCode(27)
|
|
9
|
+
const LF = String.fromCharCode(10)
|
|
10
|
+
const CR = String.fromCharCode(13)
|
|
11
|
+
const LOG = '/tmp/paste-submit.log'
|
|
12
|
+
fs.writeFileSync(LOG, '')
|
|
13
|
+
|
|
14
|
+
const child = spawn(
|
|
15
|
+
'script',
|
|
16
|
+
['-qec', 'npx tsx scripts/tui-submit.tsx', '/dev/null'],
|
|
17
|
+
{
|
|
18
|
+
cwd: path.resolve('.'),
|
|
19
|
+
env: { ...process.env, COLUMNS: '80', LINES: '24', PROBE_SUBMIT_LOG: LOG },
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
child.stdout.on('data', () => {})
|
|
23
|
+
child.stderr.on('data', () => {})
|
|
24
|
+
|
|
25
|
+
setTimeout(() => {
|
|
26
|
+
const paste =
|
|
27
|
+
ESC + '[200~' + ['l1', 'l2', 'l3', 'l4', 'l5'].join(LF) + ESC + '[201~'
|
|
28
|
+
child.stdin.write(paste)
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
child.stdin.write(CR)
|
|
31
|
+
setTimeout(() => child.kill('SIGTERM'), 800)
|
|
32
|
+
}, 800)
|
|
33
|
+
}, 4500)
|
|
34
|
+
|
|
35
|
+
child.on('close', () => {
|
|
36
|
+
process.stdout.write(fs.readFileSync(LOG, 'utf-8'))
|
|
37
|
+
process.exit(0)
|
|
38
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Reproduce the LIVE TUI scenario for the input-box geometry bug.
|
|
2
|
+
//
|
|
3
|
+
// The stock probe uses a bare `> ` prompt, an empty command list and no header, which is why the gated pty test stays green while the live agent still overprints the second input line. This probe mounts the same FullScreenApp with a realistic prompt, a non-empty slash command list and a long title.
|
|
4
|
+
//
|
|
5
|
+
// NOT shipped: a dev probe only.
|
|
6
|
+
|
|
7
|
+
import React from 'react'
|
|
8
|
+
import { render } from 'ink'
|
|
9
|
+
import { TuiStore } from '../src/tui/store.js'
|
|
10
|
+
import { FullScreenApp } from '../src/tui/FullScreenApp.js'
|
|
11
|
+
import { TuiApp } from '../src/tui/TuiApp.js'
|
|
12
|
+
import { initialEditState } from '../src/tui/edit.js'
|
|
13
|
+
|
|
14
|
+
const store = new TuiStore()
|
|
15
|
+
const commands = [
|
|
16
|
+
{ name: '/help', description: 'show help' },
|
|
17
|
+
{ name: '/history', description: 'input history' },
|
|
18
|
+
{ name: '/new', description: 'new chat' },
|
|
19
|
+
{ name: '/resume', description: 'open a chat' },
|
|
20
|
+
{ name: '/config', description: 'settings' },
|
|
21
|
+
{
|
|
22
|
+
name: '/self-review',
|
|
23
|
+
description:
|
|
24
|
+
'snapshot src/ and review the current working tree for bugs and risks',
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
store.set({
|
|
28
|
+
title: 'zames_pro',
|
|
29
|
+
version: '2.34.0',
|
|
30
|
+
workdir: '/workspaces/zames/zames_pro',
|
|
31
|
+
prompt: '❯ › ',
|
|
32
|
+
locale: process.env.PROBE_LOCALE === 'ru' ? 'ru' : 'en',
|
|
33
|
+
commands,
|
|
34
|
+
edit: { ...initialEditState(), buf: process.argv[2] ?? '' },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
store.pushLog('system', 'log line one')
|
|
38
|
+
store.pushLog('system', 'log line two')
|
|
39
|
+
|
|
40
|
+
const INLINE = process.env.PROBE_INLINE === '1'
|
|
41
|
+
|
|
42
|
+
const inputProps = {
|
|
43
|
+
onSubmit: () => {},
|
|
44
|
+
onEscape: () => {},
|
|
45
|
+
onCtrlC: () => {},
|
|
46
|
+
onCopyCode: () => {},
|
|
47
|
+
onAttach: () => null,
|
|
48
|
+
onClipboard: () => Promise.resolve(null),
|
|
49
|
+
onHistoryChange: () => {},
|
|
50
|
+
onClearScreen: () => {},
|
|
51
|
+
attachments: [],
|
|
52
|
+
onPastesChange: () => {},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
render(
|
|
56
|
+
INLINE ? (
|
|
57
|
+
<TuiApp store={store} inputProps={inputProps} />
|
|
58
|
+
) : (
|
|
59
|
+
<FullScreenApp store={store} inputProps={inputProps} />
|
|
60
|
+
),
|
|
61
|
+
{ exitOnCtrlC: false, patchConsole: false, alternateScreen: !INLINE },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
setInterval(() => {}, 1 << 30)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Resize a REAL TuiEditor under a pty and screenshot the final grid. The pty
|
|
2
|
+
// WINSIZE is changed mid-run so the kernel delivers SIGWINCH, which exercises
|
|
3
|
+
// TuiEditor.installResizeHandler (unmount + remount), not just Ink's erase.
|
|
4
|
+
//
|
|
5
|
+
// Usage: npx tsx scripts/tui-resize.mts [sc sr nc nr text]
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { Term } from '../test/vt100.mts'
|
|
8
|
+
|
|
9
|
+
const NL = String.fromCharCode(10)
|
|
10
|
+
const sc = Number(process.argv[2] || 80)
|
|
11
|
+
const sr = Number(process.argv[3] || 24)
|
|
12
|
+
const nc = Number(process.argv[4] || 100)
|
|
13
|
+
const nr = Number(process.argv[5] || 30)
|
|
14
|
+
const text = process.argv[6] || ''
|
|
15
|
+
|
|
16
|
+
// Inline python pty harness (no external file).
|
|
17
|
+
const py = [
|
|
18
|
+
'import os,pty,sys,time,fcntl,termios,struct,signal,select',
|
|
19
|
+
'sc,sr,nc,nr=' + sc + ',' + sr + ',' + nc + ',' + nr,
|
|
20
|
+
'text=' + JSON.stringify(text),
|
|
21
|
+
'pid,fd=pty.fork()',
|
|
22
|
+
'if pid==0:',
|
|
23
|
+
" os.environ['COLUMNS']=str(sc); os.environ['LINES']=str(sr)",
|
|
24
|
+
" os.execvp('npx',['npx','tsx','scripts/tui-editor-probe.tsx'])",
|
|
25
|
+
'else:',
|
|
26
|
+
" def sz(c,r): fcntl.ioctl(fd,termios.TIOCSWINSZ,struct.pack('HHHH',r,c,0,0))",
|
|
27
|
+
' sz(sc,sr)',
|
|
28
|
+
" buf=b''; start=time.time(); sent=False; resized=False",
|
|
29
|
+
' while time.time()-start<14:',
|
|
30
|
+
' t=time.time()-start',
|
|
31
|
+
' if not sent and t>5:',
|
|
32
|
+
' for ch in text:',
|
|
33
|
+
' os.write(fd,ch.encode()); time.sleep(0.12)',
|
|
34
|
+
' sent=True',
|
|
35
|
+
' if not resized and t>9:',
|
|
36
|
+
' sz(nc,nr); os.kill(pid,signal.SIGWINCH); resized=True',
|
|
37
|
+
' r,_,_=select.select([fd],[],[],0.2)',
|
|
38
|
+
' if r:',
|
|
39
|
+
' try:',
|
|
40
|
+
' d=os.read(fd,65536)',
|
|
41
|
+
' if not d: break',
|
|
42
|
+
' buf+=d',
|
|
43
|
+
' except OSError: break',
|
|
44
|
+
' time.sleep(0.5)',
|
|
45
|
+
' try:',
|
|
46
|
+
' while True:',
|
|
47
|
+
' r,_,_=select.select([fd],[],[],0.2)',
|
|
48
|
+
' if not r: break',
|
|
49
|
+
' d=os.read(fd,65536)',
|
|
50
|
+
' if not d: break',
|
|
51
|
+
' buf+=d',
|
|
52
|
+
' except OSError: pass',
|
|
53
|
+
' try: os.kill(pid,signal.SIGKILL)',
|
|
54
|
+
' except ProcessLookupError: pass',
|
|
55
|
+
' sys.stdout.buffer.write(buf)',
|
|
56
|
+
].join(NL)
|
|
57
|
+
|
|
58
|
+
const out = spawnSync('python3', ['-c', py], {
|
|
59
|
+
encoding: 'buffer',
|
|
60
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
61
|
+
})
|
|
62
|
+
const term = new Term(Math.max(sr, nr), Math.max(sc, nc))
|
|
63
|
+
term.write(out.stdout.toString('utf-8'))
|
|
64
|
+
process.stdout.write(
|
|
65
|
+
term
|
|
66
|
+
.render()
|
|
67
|
+
.split(NL)
|
|
68
|
+
.map((l, n) => String(n).padStart(2) + '| ' + l)
|
|
69
|
+
.join(NL) + NL,
|
|
70
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Press keys on the scroll probe and print the scroll values it saw. Accepts
|
|
2
|
+
// several End/Home encodings so a terminal that sends a non-xterm form can be
|
|
3
|
+
// diagnosed.
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
const ESC = String.fromCharCode(27)
|
|
8
|
+
const LOG = '/tmp/scroll-probe.log'
|
|
9
|
+
try {
|
|
10
|
+
fs.unlinkSync(LOG)
|
|
11
|
+
} catch {
|
|
12
|
+
/* ignore */
|
|
13
|
+
}
|
|
14
|
+
const map: Record<string, string> = {
|
|
15
|
+
PGUP: ESC + '[5~',
|
|
16
|
+
PGDN: ESC + '[6~',
|
|
17
|
+
HOME: ESC + '[H',
|
|
18
|
+
END: ESC + '[F',
|
|
19
|
+
END4: ESC + '[4~',
|
|
20
|
+
HOME1: ESC + '[1~',
|
|
21
|
+
CEND: ESC + '[1;5F',
|
|
22
|
+
CHOME: ESC + '[1;5H',
|
|
23
|
+
SPGDN: ESC + '[6;2~',
|
|
24
|
+
SPGUP: ESC + '[5;2~',
|
|
25
|
+
}
|
|
26
|
+
const keys = (process.argv[2] || 'PGUP').split(',').map((k) => map[k] ?? k)
|
|
27
|
+
const child = spawn(
|
|
28
|
+
'script',
|
|
29
|
+
['-qec', 'npx tsx scripts/tui-scroll-probe.tsx', '/dev/null'],
|
|
30
|
+
{
|
|
31
|
+
cwd: path.resolve('.'),
|
|
32
|
+
env: { ...process.env, COLUMNS: '80', LINES: '24', PROBE_SCROLL_LOG: LOG },
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
child.stdout.on('data', () => {})
|
|
36
|
+
child.stderr.on('data', () => {})
|
|
37
|
+
let i = 0
|
|
38
|
+
const tick = (): void => {
|
|
39
|
+
if (i < keys.length) {
|
|
40
|
+
child.stdin.write(keys[i])
|
|
41
|
+
i++
|
|
42
|
+
setTimeout(tick, 250)
|
|
43
|
+
} else setTimeout(() => child.kill('SIGTERM'), 400)
|
|
44
|
+
}
|
|
45
|
+
setTimeout(tick, 5000)
|
|
46
|
+
child.on('close', () => {
|
|
47
|
+
const lines = fs
|
|
48
|
+
.readFileSync(LOG, 'utf-8')
|
|
49
|
+
.trim()
|
|
50
|
+
.split(String.fromCharCode(10))
|
|
51
|
+
console.log('SCROLL=' + JSON.stringify(lines.map(Number)))
|
|
52
|
+
process.exit(0)
|
|
53
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Mount the REAL FullScreenApp with a LONG log and log every scroll change,
|
|
2
|
+
// so a pty can press PgUp/PgDn/Home/End and prove the viewport moves.
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import { render } from 'ink'
|
|
6
|
+
import { TuiStore } from '../src/tui/store.js'
|
|
7
|
+
import { FullScreenApp } from '../src/tui/FullScreenApp.js'
|
|
8
|
+
import { initialEditState } from '../src/tui/edit.js'
|
|
9
|
+
|
|
10
|
+
const LOG = process.env.PROBE_SCROLL_LOG || '/tmp/scroll-probe.log'
|
|
11
|
+
fs.writeFileSync(LOG, '')
|
|
12
|
+
const store = new TuiStore()
|
|
13
|
+
store.set({
|
|
14
|
+
title: 'probe',
|
|
15
|
+
version: '0',
|
|
16
|
+
workdir: '/tmp',
|
|
17
|
+
prompt: '\u2794 probe \u203a ',
|
|
18
|
+
locale: 'en',
|
|
19
|
+
commands: [],
|
|
20
|
+
edit: { ...initialEditState(), buf: '' },
|
|
21
|
+
})
|
|
22
|
+
for (let i = 0; i < 100; i++) store.pushLog('system', 'log line ' + i)
|
|
23
|
+
const inputProps = {
|
|
24
|
+
onSubmit: () => {},
|
|
25
|
+
onEscape: () => {},
|
|
26
|
+
onCtrlC: () => {},
|
|
27
|
+
onCopyCode: () => {},
|
|
28
|
+
onAttach: () => null,
|
|
29
|
+
onClipboard: () => Promise.resolve(null),
|
|
30
|
+
onHistoryChange: () => {},
|
|
31
|
+
onClearScreen: () => {},
|
|
32
|
+
attachments: [],
|
|
33
|
+
onPastesChange: () => {},
|
|
34
|
+
}
|
|
35
|
+
store.subscribe(() => {
|
|
36
|
+
fs.appendFileSync(
|
|
37
|
+
LOG,
|
|
38
|
+
String(store.getSnapshot().scroll) + String.fromCharCode(10),
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
render(React.createElement(FullScreenApp, { store, inputProps }), {
|
|
42
|
+
exitOnCtrlC: false,
|
|
43
|
+
patchConsole: false,
|
|
44
|
+
alternateScreen: true,
|
|
45
|
+
})
|
|
46
|
+
setInterval(() => {}, 1 << 30)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Take a VT100 "screenshot" of the TUI under a pty and print the grid.
|
|
2
|
+
//
|
|
3
|
+
// Usage: npx tsx scripts/tui-shot.mts [--inline] [--probe=tui-repro|tui-probe]
|
|
4
|
+
// [--buf=<initial buffer>] [--keys=a,LF,b,ENTER] [--cols=80] [--rows=24]
|
|
5
|
+
//
|
|
6
|
+
// Keys are sent ONE CHUNK AT A TIME. Tokens: LF (Ctrl+J newline), CR (Enter),
|
|
7
|
+
// ESC, TAB, or any literal character. The screen is snapshotted WHILE the app
|
|
8
|
+
// is still alive, so the real terminal-cursor cell is included.
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { Term } from '../test/vt100.mts'
|
|
13
|
+
|
|
14
|
+
const ESC = String.fromCharCode(27)
|
|
15
|
+
const LF = String.fromCharCode(10)
|
|
16
|
+
const CR = String.fromCharCode(13)
|
|
17
|
+
const TAB = String.fromCharCode(9)
|
|
18
|
+
|
|
19
|
+
function arg(name: string, def = ''): string {
|
|
20
|
+
const hit = process.argv.find((a) => a.startsWith('--' + name + '='))
|
|
21
|
+
return hit ? hit.slice(name.length + 3) : def
|
|
22
|
+
}
|
|
23
|
+
const hasFlag = (name: string): boolean => process.argv.includes('--' + name)
|
|
24
|
+
|
|
25
|
+
const inline = hasFlag('inline')
|
|
26
|
+
const probe = arg('probe', 'tui-repro')
|
|
27
|
+
const buf = arg('buf', '')
|
|
28
|
+
const keys = arg('keys', '')
|
|
29
|
+
.split(',')
|
|
30
|
+
.filter((s) => s.length > 0)
|
|
31
|
+
.map((s) => {
|
|
32
|
+
if (s === 'LF') return LF
|
|
33
|
+
if (s === 'CR' || s === 'ENTER') return CR
|
|
34
|
+
if (s === 'ESC') return ESC
|
|
35
|
+
if (s === 'TAB') return TAB
|
|
36
|
+
// Alt+Enter and Shift+Enter must arrive as ONE chunk, like a real terminal
|
|
37
|
+
// sends them; splitting them into separate chunks makes Ink read a bare
|
|
38
|
+
// Escape key instead of the modified Enter.
|
|
39
|
+
if (s === 'ALTENTER') return ESC + CR
|
|
40
|
+
if (s === 'SHIFTENTER') return ESC + '[13;2u'
|
|
41
|
+
if (s === 'UP') return ESC + '[A'
|
|
42
|
+
if (s === 'DOWN') return ESC + '[B'
|
|
43
|
+
if (s === 'RIGHT') return ESC + '[C'
|
|
44
|
+
if (s === 'LEFT') return ESC + '[D'
|
|
45
|
+
// Editing keys, so the word-delete bindings can be exercised on a real pty.
|
|
46
|
+
if (s === 'CTDL') return ESC + '[3;5~' // Ctrl+Delete (xterm)
|
|
47
|
+
if (s === 'CTDLU') return ESC + '[3;5u' // Ctrl+Delete (kitty CSI-u)
|
|
48
|
+
if (s === 'CTRLBS') return ESC + String.fromCharCode(127) // Ctrl+Backspace
|
|
49
|
+
if (s === 'CTB08') return String.fromCharCode(8) // Ctrl+Backspace (0x08)
|
|
50
|
+
if (s === 'DEL') return ESC + '[3~' // Delete
|
|
51
|
+
if (s === 'CTRLW') return String.fromCharCode(23)
|
|
52
|
+
if (s === 'CTRLU') return String.fromCharCode(21)
|
|
53
|
+
if (s === 'CTRLK') return String.fromCharCode(11)
|
|
54
|
+
if (s === 'SPACE') return ' '
|
|
55
|
+
return s
|
|
56
|
+
})
|
|
57
|
+
const cols = Number(arg('cols', '80'))
|
|
58
|
+
const rows = Number(arg('rows', '24'))
|
|
59
|
+
const hold = Number(arg('hold', '500'))
|
|
60
|
+
|
|
61
|
+
const scriptPath = 'npx tsx scripts/' + probe + '.tsx ' + JSON.stringify(buf)
|
|
62
|
+
|
|
63
|
+
const term = new Term(rows, cols)
|
|
64
|
+
const child = spawn('script', ['-qec', scriptPath, '/dev/null'], {
|
|
65
|
+
cwd: path.resolve('.'),
|
|
66
|
+
env: {
|
|
67
|
+
...process.env,
|
|
68
|
+
COLUMNS: String(cols),
|
|
69
|
+
LINES: String(rows),
|
|
70
|
+
...(inline ? { PROBE_INLINE: '1' } : {}),
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
child.stdout.on('data', (d) => term.write(d.toString('utf-8')))
|
|
74
|
+
child.stderr.on('data', (d) => term.write(d.toString('utf-8')))
|
|
75
|
+
|
|
76
|
+
let i = 0
|
|
77
|
+
const tick = (): void => {
|
|
78
|
+
if (i < keys.length) {
|
|
79
|
+
child.stdin.write(keys[i])
|
|
80
|
+
i++
|
|
81
|
+
setTimeout(tick, 250)
|
|
82
|
+
} else {
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
const cr = term.r
|
|
85
|
+
const cc = term.c
|
|
86
|
+
const lines = term.render().split(LF)
|
|
87
|
+
if (lines[cr])
|
|
88
|
+
lines[cr] =
|
|
89
|
+
lines[cr].slice(0, cc) +
|
|
90
|
+
String.fromCharCode(9673) +
|
|
91
|
+
lines[cr].slice(cc + 1)
|
|
92
|
+
process.stdout.write(
|
|
93
|
+
lines.map((l, n) => String(n).padStart(2) + '| ' + l).join(LF) +
|
|
94
|
+
LF +
|
|
95
|
+
'cursor: row ' +
|
|
96
|
+
cr +
|
|
97
|
+
', col ' +
|
|
98
|
+
cc +
|
|
99
|
+
LF,
|
|
100
|
+
)
|
|
101
|
+
child.kill('SIGTERM')
|
|
102
|
+
}, hold)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
setTimeout(tick, 4500)
|
|
106
|
+
child.on('close', () => process.exit(0))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { Term } from '../test/vt100.mts'
|
|
4
|
+
const NL = String.fromCharCode(10)
|
|
5
|
+
const cols = Number(
|
|
6
|
+
(process.argv.find((a) => a.startsWith('--cols=')) || '--cols=80').slice(7),
|
|
7
|
+
)
|
|
8
|
+
const rows = 8
|
|
9
|
+
const term = new Term(rows, cols)
|
|
10
|
+
const child = spawn(
|
|
11
|
+
'script',
|
|
12
|
+
['-qec', 'npx tsx scripts/tui-status-shot.tsx', '/dev/null'],
|
|
13
|
+
{
|
|
14
|
+
cwd: path.resolve('.'),
|
|
15
|
+
env: { ...process.env, COLUMNS: String(cols), LINES: String(rows) },
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
child.stdout.on('data', (d) => term.write(d.toString('utf-8')))
|
|
19
|
+
child.stderr.on('data', (d) => term.write(d.toString('utf-8')))
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
process.stdout.write(term.render() + NL)
|
|
22
|
+
child.kill('SIGTERM')
|
|
23
|
+
}, 5000)
|
|
24
|
+
child.on('close', () => process.exit(0))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Mount the REAL StatusBar with all badges set, to check the width math at
|
|
2
|
+
// narrow terminals (truncation must not clip the badges).
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { render } from 'ink'
|
|
5
|
+
import { StatusBar } from '../src/tui/StatusBar.js'
|
|
6
|
+
import { TuiStore } from '../src/tui/store.js'
|
|
7
|
+
const store = new TuiStore()
|
|
8
|
+
store.set({
|
|
9
|
+
locale: 'ru',
|
|
10
|
+
busy: true,
|
|
11
|
+
animBase: 'Думаю',
|
|
12
|
+
animStart: Date.now() - 45000,
|
|
13
|
+
provider: 'DeepSeek',
|
|
14
|
+
queueLength: 2,
|
|
15
|
+
tasks: 'задачи: 2/5',
|
|
16
|
+
thinkingEnabled: true,
|
|
17
|
+
searchEnabled: true,
|
|
18
|
+
contextText: 'ctx: 302k · 30%',
|
|
19
|
+
contextLevel: 'warn',
|
|
20
|
+
statusLine: 'git:master',
|
|
21
|
+
sendState: 'generating',
|
|
22
|
+
})
|
|
23
|
+
render(React.createElement(StatusBar, { state: store.getSnapshot() }), {
|
|
24
|
+
exitOnCtrlC: false,
|
|
25
|
+
patchConsole: false,
|
|
26
|
+
})
|
|
27
|
+
setInterval(() => {}, 1 << 30)
|