@researchcomputer/pista 0.1.2
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 +110 -0
- package/dist/agent-events.js +119 -0
- package/dist/agent-events.test.js +67 -0
- package/dist/app.js +608 -0
- package/dist/commands.js +673 -0
- package/dist/commands.test.js +36 -0
- package/dist/components/assistant-message-render.js +52 -0
- package/dist/components/assistant-message-render.test.js +9 -0
- package/dist/components/assistant-message.js +9 -0
- package/dist/components/editor.js +65 -0
- package/dist/components/editor.test.js +40 -0
- package/dist/components/frame.js +22 -0
- package/dist/components/header.js +63 -0
- package/dist/components/header.test.js +12 -0
- package/dist/components/log-row.js +28 -0
- package/dist/components/picker.js +10 -0
- package/dist/components/prompt.js +8 -0
- package/dist/components/scrollbar.js +15 -0
- package/dist/components/slash-command-menu.js +9 -0
- package/dist/config.js +168 -0
- package/dist/config.test.js +45 -0
- package/dist/index.js +26 -0
- package/dist/model.js +17 -0
- package/dist/transcript.js +21 -0
- package/dist/transcript.test.js +33 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +219 -0
- package/dist/utils.test.js +97 -0
- package/package.json +34 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { getSlashCommandSuggestions, parseSlashCommand } from './commands.js';
|
|
4
|
+
test('parseSlashCommand lowercases the command and preserves the rest', () => {
|
|
5
|
+
assert.deepEqual(parseSlashCommand('/Plugins Install owner/repo'), {
|
|
6
|
+
command: 'plugins',
|
|
7
|
+
args: ['Install', 'owner/repo'],
|
|
8
|
+
rest: 'Install owner/repo',
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
test('getSlashCommandSuggestions shows top-level commands for bare slash input', () => {
|
|
12
|
+
const suggestions = getSlashCommandSuggestions('/', false, 4);
|
|
13
|
+
assert.deepEqual(suggestions.map((item) => item.label), ['/help', '/init', '/name [name]', '/abort']);
|
|
14
|
+
});
|
|
15
|
+
test('getSlashCommandSuggestions prioritizes exact and prefix matches', () => {
|
|
16
|
+
const suggestions = getSlashCommandSuggestions('/plug', false, 4);
|
|
17
|
+
assert.deepEqual(suggestions.map((item) => item.label), ['/plugins', '/plugins add', '/plugins install', '/plugins browse']);
|
|
18
|
+
});
|
|
19
|
+
test('getSlashCommandSuggestions returns subcommand completions with trailing spaces when needed', () => {
|
|
20
|
+
const [jump] = getSlashCommandSuggestions('/jump', false, 1);
|
|
21
|
+
const [pluginsAdd] = getSlashCommandSuggestions('/plugins a', false, 1);
|
|
22
|
+
assert.equal(jump?.insertText, '/jump ');
|
|
23
|
+
assert.equal(jump?.commandText, '/jump');
|
|
24
|
+
assert.equal(pluginsAdd?.insertText, '/plugins add ');
|
|
25
|
+
assert.equal(pluginsAdd?.commandText, '/plugins add');
|
|
26
|
+
});
|
|
27
|
+
test('getSlashCommandSuggestions hides unavailable commands while the agent is running', () => {
|
|
28
|
+
const suggestions = getSlashCommandSuggestions('/', true, 10);
|
|
29
|
+
assert.deepEqual(suggestions.map((item) => item.label), ['/help', '/abort']);
|
|
30
|
+
});
|
|
31
|
+
test('getSlashCommandSuggestions hides the popup once arguments are being typed', () => {
|
|
32
|
+
assert.deepEqual(getSlashCommandSuggestions('/model gpt-5', false), []);
|
|
33
|
+
});
|
|
34
|
+
test('getSlashCommandSuggestions hides the popup after a completion adds a trailing space', () => {
|
|
35
|
+
assert.deepEqual(getSlashCommandSuggestions('/plugins add ', false), []);
|
|
36
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const FENCE_RE = /^(```|~~~)/;
|
|
2
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
|
3
|
+
const BULLET_RE = /^(\s*)[-*+]\s+(.*)$/;
|
|
4
|
+
const ORDERED_RE = /^(\s*)(\d+)\.\s+(.*)$/;
|
|
5
|
+
const BLOCKQUOTE_RE = /^>\s?(.*)$/;
|
|
6
|
+
const HORIZONTAL_RULE_RE = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
|
|
7
|
+
function stripInlineMarkdown(text) {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => (alt ? `${alt} (${url})` : url))
|
|
10
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
|
11
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
12
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
13
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
14
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
15
|
+
.replace(/_([^_]+)_/g, '$1')
|
|
16
|
+
.replace(/~~([^~]+)~~/g, '$1');
|
|
17
|
+
}
|
|
18
|
+
function formatMarkdownLine(line) {
|
|
19
|
+
if (HORIZONTAL_RULE_RE.test(line)) {
|
|
20
|
+
return '────────';
|
|
21
|
+
}
|
|
22
|
+
const heading = line.match(HEADING_RE);
|
|
23
|
+
if (heading) {
|
|
24
|
+
return stripInlineMarkdown(heading[2]).trim();
|
|
25
|
+
}
|
|
26
|
+
const bullet = line.match(BULLET_RE);
|
|
27
|
+
if (bullet) {
|
|
28
|
+
return `${bullet[1]}• ${stripInlineMarkdown(bullet[2])}`;
|
|
29
|
+
}
|
|
30
|
+
const ordered = line.match(ORDERED_RE);
|
|
31
|
+
if (ordered) {
|
|
32
|
+
return `${ordered[1]}${ordered[2]}. ${stripInlineMarkdown(ordered[3])}`;
|
|
33
|
+
}
|
|
34
|
+
const quote = line.match(BLOCKQUOTE_RE);
|
|
35
|
+
if (quote) {
|
|
36
|
+
return `| ${stripInlineMarkdown(quote[1])}`;
|
|
37
|
+
}
|
|
38
|
+
return stripInlineMarkdown(line);
|
|
39
|
+
}
|
|
40
|
+
export function renderMarkdownLines(content) {
|
|
41
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
42
|
+
const rendered = [];
|
|
43
|
+
let inFence = false;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (FENCE_RE.test(line.trim())) {
|
|
46
|
+
inFence = !inFence;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
rendered.push(inFence ? line : formatMarkdownLine(line));
|
|
50
|
+
}
|
|
51
|
+
return rendered;
|
|
52
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { renderMarkdownLines } from './assistant-message-render.js';
|
|
4
|
+
test('renderMarkdownLines flattens common markdown formatting for terminal output', () => {
|
|
5
|
+
assert.deepEqual(renderMarkdownLines('# Title\n\nSee [docs](https://example.com) and **bold** text.'), ['Title', '', 'See docs (https://example.com) and bold text.']);
|
|
6
|
+
});
|
|
7
|
+
test('renderMarkdownLines preserves code blocks while normalizing block syntax', () => {
|
|
8
|
+
assert.deepEqual(renderMarkdownLines('> note\n- item\n1. step\n---\n```ts\nconst value = 1;\n```'), ['| note', '• item', '1. step', '────────', 'const value = 1;']);
|
|
9
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { renderMarkdownLines } from './assistant-message-render.js';
|
|
5
|
+
export const AssistantMessage = React.memo(function AssistantMessage({ content, showSpinner = false, spinner, }) {
|
|
6
|
+
const body = content.trimEnd();
|
|
7
|
+
const lines = renderMarkdownLines(body);
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", children: [body ? lines.map((line, index) => (_jsx(Text, { children: line || ' ' }, index))) : _jsx(Text, { dimColor: true, children: "[no content]" }), showSpinner && spinner ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: " " }), spinner] })) : null] }));
|
|
9
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
export function Editor({ value, cursor, placeholder, secret, }) {
|
|
4
|
+
const content = secret ? '*'.repeat(value.length) : value;
|
|
5
|
+
const chars = Array.from(content);
|
|
6
|
+
const safeCursor = Math.max(0, Math.min(cursor, chars.length));
|
|
7
|
+
if (chars.length === 0) {
|
|
8
|
+
return (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { inverse: true, children: " " }), placeholder ?? ''] }));
|
|
9
|
+
}
|
|
10
|
+
const before = chars.slice(0, safeCursor).join('');
|
|
11
|
+
const current = chars[safeCursor] ?? ' ';
|
|
12
|
+
const after = chars.slice(safeCursor + 1).join('');
|
|
13
|
+
return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: current }), after] }));
|
|
14
|
+
}
|
|
15
|
+
export function editBuffer(current, input, key) {
|
|
16
|
+
const chars = Array.from(current.value);
|
|
17
|
+
let cursor = Math.max(0, Math.min(current.cursor, chars.length));
|
|
18
|
+
const isBackspaceInput = input === '\x7f' || input === '\b' || input === '\x08' || (key.ctrl && input === 'h');
|
|
19
|
+
const shouldTreatDeleteAsBackspace = key.delete && !input && cursor === chars.length && cursor > 0;
|
|
20
|
+
const isBackspace = key.backspace || isBackspaceInput || shouldTreatDeleteAsBackspace;
|
|
21
|
+
if (isBackspace) {
|
|
22
|
+
if (cursor > 0) {
|
|
23
|
+
chars.splice(cursor - 1, 1);
|
|
24
|
+
cursor -= 1;
|
|
25
|
+
}
|
|
26
|
+
return { ...current, value: chars.join(''), cursor };
|
|
27
|
+
}
|
|
28
|
+
// Delete - only if it wasn't already handled as a backspace
|
|
29
|
+
if (key.delete) {
|
|
30
|
+
if (cursor < chars.length) {
|
|
31
|
+
chars.splice(cursor, 1);
|
|
32
|
+
}
|
|
33
|
+
return { ...current, value: chars.join(''), cursor };
|
|
34
|
+
}
|
|
35
|
+
// Navigation
|
|
36
|
+
if (key.leftArrow) {
|
|
37
|
+
return { ...current, cursor: Math.max(0, cursor - 1) };
|
|
38
|
+
}
|
|
39
|
+
if (key.rightArrow) {
|
|
40
|
+
return { ...current, cursor: Math.min(chars.length, cursor + 1) };
|
|
41
|
+
}
|
|
42
|
+
if (key.home) {
|
|
43
|
+
return { ...current, cursor: 0 };
|
|
44
|
+
}
|
|
45
|
+
if (key.end) {
|
|
46
|
+
return { ...current, cursor: chars.length };
|
|
47
|
+
}
|
|
48
|
+
// Ignore control keys
|
|
49
|
+
if (key.ctrl || key.meta || key.return || key.tab || key.escape) {
|
|
50
|
+
return current;
|
|
51
|
+
}
|
|
52
|
+
// Character insertion
|
|
53
|
+
if (input && input.length > 0) {
|
|
54
|
+
const inputChars = Array.from(input).filter(c => {
|
|
55
|
+
const code = c.charCodeAt(0);
|
|
56
|
+
return code >= 32 && code !== 127;
|
|
57
|
+
});
|
|
58
|
+
if (inputChars.length > 0) {
|
|
59
|
+
chars.splice(cursor, 0, ...inputChars);
|
|
60
|
+
cursor += inputChars.length;
|
|
61
|
+
}
|
|
62
|
+
return { ...current, value: chars.join(''), cursor };
|
|
63
|
+
}
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { editBuffer } from './editor.js';
|
|
4
|
+
function key(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
leftArrow: false,
|
|
7
|
+
rightArrow: false,
|
|
8
|
+
home: false,
|
|
9
|
+
end: false,
|
|
10
|
+
shift: false,
|
|
11
|
+
backspace: false,
|
|
12
|
+
delete: false,
|
|
13
|
+
return: false,
|
|
14
|
+
ctrl: false,
|
|
15
|
+
meta: false,
|
|
16
|
+
tab: false,
|
|
17
|
+
escape: false,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
test('backspace removes the character before the cursor', () => {
|
|
22
|
+
const result = editBuffer({ value: 'abc', cursor: 3 }, '', key({ backspace: true }));
|
|
23
|
+
assert.deepEqual(result, { value: 'ab', cursor: 2 });
|
|
24
|
+
});
|
|
25
|
+
test('DEL input is treated as backspace', () => {
|
|
26
|
+
const result = editBuffer({ value: 'abc', cursor: 3 }, '\x7f', key());
|
|
27
|
+
assert.deepEqual(result, { value: 'ab', cursor: 2 });
|
|
28
|
+
});
|
|
29
|
+
test('delete at end of line behaves like backspace for terminals that map it that way', () => {
|
|
30
|
+
const result = editBuffer({ value: 'abc', cursor: 3 }, '', key({ delete: true }));
|
|
31
|
+
assert.deepEqual(result, { value: 'ab', cursor: 2 });
|
|
32
|
+
});
|
|
33
|
+
test('delete inside the line still removes the character at the cursor', () => {
|
|
34
|
+
const result = editBuffer({ value: 'abcd', cursor: 1 }, '', key({ delete: true }));
|
|
35
|
+
assert.deepEqual(result, { value: 'acd', cursor: 1 });
|
|
36
|
+
});
|
|
37
|
+
test('ctrl+key combinations are ignored by the editor buffer', () => {
|
|
38
|
+
const result = editBuffer({ value: 'abcd', cursor: 2 }, 'p', key({ ctrl: true }));
|
|
39
|
+
assert.deepEqual(result, { value: 'abcd', cursor: 2 });
|
|
40
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
export const Frame = React.memo(function Frame({ children, borderColor, topLeft, topRight, bottomLeft, bottomRight, paddingX = 1, }) {
|
|
5
|
+
const termWidth = process.stdout.columns || 80;
|
|
6
|
+
const usableWidth = termWidth - 2; // account for outer padding in app
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(TopBorder, { width: usableWidth, color: borderColor, left: topLeft, right: topRight }), _jsx(Box, { flexDirection: "column", paddingX: paddingX, children: children }), _jsx(BottomBorder, { width: usableWidth, color: borderColor, left: bottomLeft, right: bottomRight })] }));
|
|
8
|
+
});
|
|
9
|
+
function TopBorder({ width, color, left, right }) {
|
|
10
|
+
const leftText = left ? ` ${left} ` : '';
|
|
11
|
+
const rightText = right ? ` ${right} ` : '';
|
|
12
|
+
const fillLen = Math.max(0, width - 3 - leftText.length - rightText.length);
|
|
13
|
+
const fill = '─'.repeat(fillLen);
|
|
14
|
+
return (_jsxs(Text, { color: color, children: ["\u256D\u2500", leftText, fill, rightText, "\u256E"] }));
|
|
15
|
+
}
|
|
16
|
+
function BottomBorder({ width, color, left, right }) {
|
|
17
|
+
const leftText = left ? ` ${left} ` : '';
|
|
18
|
+
const rightText = right ? ` ${right} ` : '';
|
|
19
|
+
const fillLen = Math.max(0, width - 3 - leftText.length - rightText.length);
|
|
20
|
+
const fill = '─'.repeat(fillLen);
|
|
21
|
+
return (_jsxs(Text, { color: color, children: ["\u2570\u2500", leftText, fill, rightText, "\u256F"] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { Frame } from './frame.js';
|
|
5
|
+
function stripEndpoint(endpoint) {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(endpoint);
|
|
8
|
+
return url.hostname;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return endpoint;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function truncateStr(value, max) {
|
|
15
|
+
if (value.length <= max)
|
|
16
|
+
return value;
|
|
17
|
+
if (max <= 1)
|
|
18
|
+
return '…';
|
|
19
|
+
return value.slice(0, max - 1) + '…';
|
|
20
|
+
}
|
|
21
|
+
function statusColor(status) {
|
|
22
|
+
if (status === 'Error')
|
|
23
|
+
return 'redBright';
|
|
24
|
+
if (status === 'Idle')
|
|
25
|
+
return 'greenBright';
|
|
26
|
+
return 'yellowBright';
|
|
27
|
+
}
|
|
28
|
+
export function formatPendingToolsLine(pendingTools, termWidth) {
|
|
29
|
+
if (pendingTools.length === 0)
|
|
30
|
+
return ' ';
|
|
31
|
+
const joined = pendingTools.join(', ');
|
|
32
|
+
const maxToolWidth = termWidth - 8;
|
|
33
|
+
if (joined.length <= maxToolWidth) {
|
|
34
|
+
return `▸ ${joined}`;
|
|
35
|
+
}
|
|
36
|
+
const shown = [];
|
|
37
|
+
let len = 0;
|
|
38
|
+
for (const tool of pendingTools) {
|
|
39
|
+
const next = shown.length > 0 ? len + 2 + tool.length : tool.length;
|
|
40
|
+
const remaining = pendingTools.length - shown.length - 1;
|
|
41
|
+
const suffixLen = remaining > 0 ? ` …${remaining} more`.length : 0;
|
|
42
|
+
if (next + suffixLen > maxToolWidth && shown.length > 0) {
|
|
43
|
+
return `▸ ${shown.join(', ')} …${remaining + 1} more`;
|
|
44
|
+
}
|
|
45
|
+
shown.push(tool);
|
|
46
|
+
len = next;
|
|
47
|
+
}
|
|
48
|
+
return `▸ ${shown.join(', ')}`;
|
|
49
|
+
}
|
|
50
|
+
export const Header = React.memo(function Header({ agentName, sessionId, model, apiStyle, endpoint, permissionMode, thinkingLevel, status, pendingTools, }) {
|
|
51
|
+
const color = statusColor(status);
|
|
52
|
+
const termWidth = process.stdout.columns || 80;
|
|
53
|
+
// Build config line with progressive truncation for narrow terminals
|
|
54
|
+
const configParts = [model, apiStyle];
|
|
55
|
+
if (termWidth >= 60 && permissionMode !== 'default')
|
|
56
|
+
configParts.push(permissionMode);
|
|
57
|
+
if (termWidth >= 70 && thinkingLevel !== 'off')
|
|
58
|
+
configParts.push(`thinking:${thinkingLevel}`);
|
|
59
|
+
const host = truncateStr(stripEndpoint(endpoint), Math.max(10, termWidth - 30));
|
|
60
|
+
const toolsLine = formatPendingToolsLine(pendingTools, termWidth);
|
|
61
|
+
const toolsActive = pendingTools.length > 0;
|
|
62
|
+
return (_jsxs(Frame, { borderColor: "gray", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: "cyanBright", children: ["\u25CF ", truncateStr(agentName, Math.max(4, termWidth - status.length - 8))] }), _jsxs(Text, { color: color, children: ["\u25CF ", status] })] }), _jsx(Text, { dimColor: true, wrap: "truncate", children: configParts.join(' · ') }), _jsxs(Text, { dimColor: true, wrap: "truncate", children: [host, " session:", sessionId.slice(0, 8)] }), _jsx(Text, { color: toolsActive ? 'yellow' : undefined, dimColor: !toolsActive, wrap: "truncate", children: toolsLine })] }));
|
|
63
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { formatPendingToolsLine } from './header.js';
|
|
4
|
+
test('formatPendingToolsLine keeps a placeholder row when there are no pending tools', () => {
|
|
5
|
+
assert.equal(formatPendingToolsLine([], 80), ' ');
|
|
6
|
+
});
|
|
7
|
+
test('formatPendingToolsLine shows the full tool list when it fits', () => {
|
|
8
|
+
assert.equal(formatPendingToolsLine(['shell', 'grep'], 80), '▸ shell, grep');
|
|
9
|
+
});
|
|
10
|
+
test('formatPendingToolsLine truncates long tool lists for narrow terminals', () => {
|
|
11
|
+
assert.equal(formatPendingToolsLine(['very-long-tool-name', 'another-tool', 'third-tool'], 24), '▸ very-long-tool-name …2 more');
|
|
12
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { logColor } from '../utils.js';
|
|
5
|
+
import { AssistantMessage } from './assistant-message.js';
|
|
6
|
+
const SYMBOLS = {
|
|
7
|
+
system: '◆',
|
|
8
|
+
user: '▷',
|
|
9
|
+
assistant: '●',
|
|
10
|
+
tool: '▸',
|
|
11
|
+
error: '✗',
|
|
12
|
+
};
|
|
13
|
+
function formatTime(timestamp) {
|
|
14
|
+
const d = new Date(timestamp);
|
|
15
|
+
const h = String(d.getHours()).padStart(2, '0');
|
|
16
|
+
const m = String(d.getMinutes()).padStart(2, '0');
|
|
17
|
+
const s = String(d.getSeconds()).padStart(2, '0');
|
|
18
|
+
return `${h}:${m}:${s}`;
|
|
19
|
+
}
|
|
20
|
+
export const LogRow = React.memo(function LogRow({ entry }) {
|
|
21
|
+
const color = logColor(entry.kind);
|
|
22
|
+
const symbol = SYMBOLS[entry.kind] ?? '·';
|
|
23
|
+
const time = formatTime(entry.timestamp);
|
|
24
|
+
const bodyLines = (entry.body || '[no content]').split('\n');
|
|
25
|
+
const termWidth = process.stdout.columns || 80;
|
|
26
|
+
const showTime = termWidth >= 50;
|
|
27
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: color, bold: true, children: [symbol, " ", entry.title] }), showTime ? _jsx(Text, { dimColor: true, children: time }) : null] }), _jsx(Box, { paddingLeft: 2, flexDirection: "column", children: entry.kind === 'assistant' ? (_jsx(AssistantMessage, { content: entry.body || '[no content]' })) : (bodyLines.map((line, index) => (_jsx(Text, { wrap: "truncate", children: line || ' ' }, `${entry.id}-${index}`)))) })] }));
|
|
28
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Frame } from './frame.js';
|
|
4
|
+
export function PickerPanel({ pickerState }) {
|
|
5
|
+
const descriptionLines = pickerState.description ? pickerState.description.split('\n') : [];
|
|
6
|
+
return (_jsxs(Frame, { borderColor: "blueBright", topLeft: pickerState.title, bottomLeft: "\u2191\u2193 choose \u00B7 Enter confirm \u00B7 Esc", children: [descriptionLines.length > 0 ? (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: descriptionLines.map((line, index) => (_jsx(Text, { dimColor: true, children: line || ' ' }, `desc-${index}`))) })) : null, _jsx(Box, { flexDirection: "column", children: pickerState.items.length === 0 ? (_jsx(Text, { dimColor: true, children: pickerState.emptyMessage ?? 'No items available.' })) : (pickerState.items.map((item, index) => {
|
|
7
|
+
const selected = index === pickerState.selectedIndex;
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "cyanBright", children: selected ? ' › ' : ' ' }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: selected, color: selected ? 'white' : 'gray', children: item.label }), item.description ? (_jsx(Text, { dimColor: true, italic: true, children: item.description })) : null] })] }, item.id));
|
|
9
|
+
})) })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Frame } from './frame.js';
|
|
4
|
+
import { Editor } from './editor.js';
|
|
5
|
+
export function PromptPanel({ promptState }) {
|
|
6
|
+
const descriptionLines = promptState.description ? promptState.description.split('\n') : [];
|
|
7
|
+
return (_jsxs(Frame, { borderColor: "yellow", topLeft: promptState.title, bottomLeft: "Enter submit \u00B7 Esc cancel", children: [descriptionLines.length > 0 ? (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: descriptionLines.map((line, index) => (_jsx(Text, { dimColor: true, children: line || ' ' }, `desc-${index}`))) })) : null, _jsxs(Box, { children: [_jsx(Text, { color: "yellowBright", bold: true, children: ': ' }), _jsx(Editor, { value: promptState.value, cursor: promptState.cursor, placeholder: promptState.placeholder, secret: promptState.secret })] })] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
export const Scrollbar = React.memo(function Scrollbar({ trackHeight, totalEntries, visibleEntries, offset, }) {
|
|
5
|
+
if (totalEntries <= visibleEntries || trackHeight < 1) {
|
|
6
|
+
return (_jsx(Box, { flexDirection: "column", width: 2, children: Array.from({ length: trackHeight }, (_, i) => (_jsx(Text, { dimColor: true, children: " " }, i))) }));
|
|
7
|
+
}
|
|
8
|
+
const thumbSize = Math.max(1, Math.round((visibleEntries / totalEntries) * trackHeight));
|
|
9
|
+
const maxOffset = totalEntries - visibleEntries;
|
|
10
|
+
const thumbPosition = Math.round(((maxOffset - offset) / maxOffset) * (trackHeight - thumbSize));
|
|
11
|
+
return (_jsx(Box, { flexDirection: "column", width: 2, children: Array.from({ length: trackHeight }, (_, i) => {
|
|
12
|
+
const isThumb = i >= thumbPosition && i < thumbPosition + thumbSize;
|
|
13
|
+
return (_jsx(Text, { dimColor: !isThumb, children: isThumb ? ' █' : ' ░' }, i));
|
|
14
|
+
}) }));
|
|
15
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Frame } from './frame.js';
|
|
4
|
+
export function SlashCommandMenu({ items, selectedIndex, }) {
|
|
5
|
+
return (_jsx(Frame, { borderColor: "magentaBright", topLeft: "Commands", bottomLeft: "\u2191\u2193 choose \u00B7 Tab/Enter complete", children: _jsx(Box, { flexDirection: "column", children: items.map((item, index) => {
|
|
6
|
+
const selected = index === selectedIndex;
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "magentaBright", children: selected ? ' › ' : ' ' }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: selected, color: selected ? 'white' : 'gray', children: item.label }), _jsx(Text, { dimColor: true, children: item.description })] })] }, item.id));
|
|
8
|
+
}) }) }));
|
|
9
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { loadResolvedPreferences, normalizeAgentName, normalizeEndpoint, resolveConfiguredSkills } from './utils.js';
|
|
5
|
+
export const COMPAT_DEFAULT_ENDPOINT = 'https://api.research.computer/v1';
|
|
6
|
+
export const COMPAT_DEFAULT_MODEL = 'zai-org/GLM-4.7-Flash';
|
|
7
|
+
export const COMPAT_DEFAULT_API_STYLE = 'chat';
|
|
8
|
+
export function parsePermissionMode(value) {
|
|
9
|
+
if (!value)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (value === 'default' || value === 'allowAll' || value === 'rulesOnly')
|
|
12
|
+
return value;
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function parseThinkingLevel(value) {
|
|
16
|
+
if (!value)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (value === 'off' ||
|
|
19
|
+
value === 'minimal' ||
|
|
20
|
+
value === 'low' ||
|
|
21
|
+
value === 'medium' ||
|
|
22
|
+
value === 'high' ||
|
|
23
|
+
value === 'xhigh') {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export function parseCompatApiStyle(value) {
|
|
29
|
+
if (!value)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (value === 'chat' || value === 'responses')
|
|
32
|
+
return value;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
export function describeConfig(config) {
|
|
36
|
+
const skillNames = config.skills.map((skill) => skill.id).join(', ') || 'none';
|
|
37
|
+
return [
|
|
38
|
+
`Agent ${config.agentName}`,
|
|
39
|
+
`CWD ${config.cwd}`,
|
|
40
|
+
`Session ${config.sessionId}`,
|
|
41
|
+
`Permissions ${config.permissionMode}`,
|
|
42
|
+
`Thinking ${config.thinkingLevel}`,
|
|
43
|
+
`Endpoint ${config.selection.endpoint}`,
|
|
44
|
+
`Model ${config.selection.model}`,
|
|
45
|
+
`API style ${config.selection.apiStyle}`,
|
|
46
|
+
`Skills ${skillNames}`,
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
function requireValue(flag, value) {
|
|
50
|
+
if (!value) {
|
|
51
|
+
throw new Error(`Missing value for ${flag}`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
export function parseArgs(argv) {
|
|
56
|
+
const resolvedCwd = resolveCliCwd(argv);
|
|
57
|
+
const storedPreferences = loadResolvedPreferences(resolvedCwd);
|
|
58
|
+
const envApiStyle = parseCompatApiStyle(process.env.PISTA_API_STYLE);
|
|
59
|
+
const selection = {
|
|
60
|
+
endpoint: process.env.PISTA_ENDPOINT ?? storedPreferences.selection?.endpoint ?? COMPAT_DEFAULT_ENDPOINT,
|
|
61
|
+
model: process.env.PISTA_MODEL ?? storedPreferences.selection?.model ?? COMPAT_DEFAULT_MODEL,
|
|
62
|
+
apiStyle: envApiStyle ?? storedPreferences.selection?.apiStyle ?? COMPAT_DEFAULT_API_STYLE,
|
|
63
|
+
};
|
|
64
|
+
const config = {
|
|
65
|
+
agentName: normalizeAgentName(process.env.PISTA_NAME ?? 'Pista'),
|
|
66
|
+
cwd: resolvedCwd,
|
|
67
|
+
sessionId: process.env.PISTA_SESSION_ID ?? randomUUID(),
|
|
68
|
+
permissionMode: parsePermissionMode(process.env.PISTA_PERMISSION_MODE) ?? 'default',
|
|
69
|
+
thinkingLevel: parseThinkingLevel(process.env.PISTA_THINKING) ?? 'off',
|
|
70
|
+
selection,
|
|
71
|
+
skills: resolveConfiguredSkills(storedPreferences.skills),
|
|
72
|
+
};
|
|
73
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
74
|
+
const arg = argv[index];
|
|
75
|
+
const nextValue = argv[index + 1];
|
|
76
|
+
if (arg === '--help' || arg === '-h') {
|
|
77
|
+
printHelp();
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
switch (arg) {
|
|
81
|
+
case '--cwd':
|
|
82
|
+
config.cwd = resolve(requireValue(arg, nextValue));
|
|
83
|
+
index += 1;
|
|
84
|
+
break;
|
|
85
|
+
case '--name':
|
|
86
|
+
config.agentName = normalizeAgentName(requireValue(arg, nextValue));
|
|
87
|
+
index += 1;
|
|
88
|
+
break;
|
|
89
|
+
case '--session':
|
|
90
|
+
config.sessionId = requireValue(arg, nextValue);
|
|
91
|
+
index += 1;
|
|
92
|
+
break;
|
|
93
|
+
case '--permission-mode': {
|
|
94
|
+
const permissionMode = parsePermissionMode(requireValue(arg, nextValue));
|
|
95
|
+
if (!permissionMode) {
|
|
96
|
+
throw new Error('Expected permission mode to be one of: default, allowAll, rulesOnly');
|
|
97
|
+
}
|
|
98
|
+
config.permissionMode = permissionMode;
|
|
99
|
+
index += 1;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case '--thinking': {
|
|
103
|
+
const thinkingLevel = parseThinkingLevel(requireValue(arg, nextValue));
|
|
104
|
+
if (!thinkingLevel) {
|
|
105
|
+
throw new Error('Expected thinking level to be one of: off, minimal, low, medium, high, xhigh');
|
|
106
|
+
}
|
|
107
|
+
config.thinkingLevel = thinkingLevel;
|
|
108
|
+
index += 1;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case '--model':
|
|
112
|
+
case '-m':
|
|
113
|
+
config.selection.model = requireValue(arg, nextValue);
|
|
114
|
+
index += 1;
|
|
115
|
+
break;
|
|
116
|
+
case '--endpoint':
|
|
117
|
+
config.selection.endpoint = normalizeEndpoint(requireValue(arg, nextValue));
|
|
118
|
+
index += 1;
|
|
119
|
+
break;
|
|
120
|
+
case '--api-style': {
|
|
121
|
+
const apiStyle = parseCompatApiStyle(requireValue(arg, nextValue));
|
|
122
|
+
if (!apiStyle) {
|
|
123
|
+
throw new Error('Expected API style to be "chat" or "responses".');
|
|
124
|
+
}
|
|
125
|
+
config.selection.apiStyle = apiStyle;
|
|
126
|
+
index += 1;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
validateConfig(config);
|
|
134
|
+
return config;
|
|
135
|
+
}
|
|
136
|
+
function validateConfig(config) {
|
|
137
|
+
normalizeEndpoint(config.selection.endpoint);
|
|
138
|
+
if (!config.selection.model.trim()) {
|
|
139
|
+
throw new Error('Model id is required.');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function resolveCliCwd(argv) {
|
|
143
|
+
let cwd = resolve(process.env.PISTA_CWD ?? process.cwd());
|
|
144
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
145
|
+
if (argv[index] === '--cwd') {
|
|
146
|
+
cwd = resolve(requireValue('--cwd', argv[index + 1]));
|
|
147
|
+
index += 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return cwd;
|
|
151
|
+
}
|
|
152
|
+
function printHelp() {
|
|
153
|
+
process.stdout.write([
|
|
154
|
+
'Usage: pista [options]',
|
|
155
|
+
'',
|
|
156
|
+
'Options:',
|
|
157
|
+
' --model, -m <id> Model id (default: gpt-4o-mini)',
|
|
158
|
+
' --name <agent-name> Display name for the coding agent',
|
|
159
|
+
' --endpoint <url> Base URL (default: https://api.research.computer/v1)',
|
|
160
|
+
' --api-style <chat|responses> API style (default: chat)',
|
|
161
|
+
' --cwd <path> Working directory for the agent',
|
|
162
|
+
' --session <id> Session id',
|
|
163
|
+
' --permission-mode <mode> default | allowAll | rulesOnly',
|
|
164
|
+
' --thinking <level> off | minimal | low | medium | high | xhigh',
|
|
165
|
+
' --help, -h Show this help',
|
|
166
|
+
'',
|
|
167
|
+
].join('\n'));
|
|
168
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseArgs } from './config.js';
|
|
4
|
+
test('parseArgs reads PISTA_* environment overrides', () => {
|
|
5
|
+
const previous = {
|
|
6
|
+
PISTA_MODEL: process.env.PISTA_MODEL,
|
|
7
|
+
PISTA_NAME: process.env.PISTA_NAME,
|
|
8
|
+
PISTA_ENDPOINT: process.env.PISTA_ENDPOINT,
|
|
9
|
+
PISTA_API_STYLE: process.env.PISTA_API_STYLE,
|
|
10
|
+
PISTA_CWD: process.env.PISTA_CWD,
|
|
11
|
+
PISTA_SESSION_ID: process.env.PISTA_SESSION_ID,
|
|
12
|
+
PISTA_PERMISSION_MODE: process.env.PISTA_PERMISSION_MODE,
|
|
13
|
+
PISTA_THINKING: process.env.PISTA_THINKING,
|
|
14
|
+
};
|
|
15
|
+
process.env.PISTA_MODEL = 'env-model';
|
|
16
|
+
process.env.PISTA_NAME = 'Env Agent';
|
|
17
|
+
process.env.PISTA_ENDPOINT = 'https://env.example/v1';
|
|
18
|
+
process.env.PISTA_API_STYLE = 'responses';
|
|
19
|
+
process.env.PISTA_CWD = '/tmp/pista-cwd';
|
|
20
|
+
process.env.PISTA_SESSION_ID = 'env-session';
|
|
21
|
+
process.env.PISTA_PERMISSION_MODE = 'allowAll';
|
|
22
|
+
process.env.PISTA_THINKING = 'high';
|
|
23
|
+
try {
|
|
24
|
+
const config = parseArgs([]);
|
|
25
|
+
assert.ok(config);
|
|
26
|
+
assert.equal(config.selection.model, 'env-model');
|
|
27
|
+
assert.equal(config.agentName, 'Env Agent');
|
|
28
|
+
assert.equal(config.selection.endpoint, 'https://env.example/v1');
|
|
29
|
+
assert.equal(config.selection.apiStyle, 'responses');
|
|
30
|
+
assert.equal(config.cwd, '/tmp/pista-cwd');
|
|
31
|
+
assert.equal(config.sessionId, 'env-session');
|
|
32
|
+
assert.equal(config.permissionMode, 'allowAll');
|
|
33
|
+
assert.equal(config.thinkingLevel, 'high');
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
delete process.env[key];
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
process.env[key] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import { App } from './app.js';
|
|
6
|
+
import { parseArgs } from './config.js';
|
|
7
|
+
import { errorMessage } from './utils.js';
|
|
8
|
+
async function main() {
|
|
9
|
+
const config = parseArgs(process.argv.slice(2));
|
|
10
|
+
if (!config)
|
|
11
|
+
return;
|
|
12
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
13
|
+
throw new Error('This example CLI requires an interactive TTY.');
|
|
14
|
+
}
|
|
15
|
+
const app = render(_jsx(App, { initialConfig: config }), {
|
|
16
|
+
exitOnCtrlC: false,
|
|
17
|
+
incrementalRendering: true,
|
|
18
|
+
patchConsole: true,
|
|
19
|
+
maxFps: 30,
|
|
20
|
+
});
|
|
21
|
+
await app.waitUntilExit();
|
|
22
|
+
}
|
|
23
|
+
void main().catch((error) => {
|
|
24
|
+
process.stderr.write(`Error: ${errorMessage(error)}\n`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function resolveModel(selection, thinkingLevel = 'off') {
|
|
2
|
+
return {
|
|
3
|
+
id: selection.model,
|
|
4
|
+
name: selection.model,
|
|
5
|
+
api: selection.apiStyle === 'responses' ? 'openai-responses' : 'openai-completions',
|
|
6
|
+
provider: 'openai-compatible',
|
|
7
|
+
baseUrl: selection.endpoint,
|
|
8
|
+
reasoning: thinkingLevel !== 'off',
|
|
9
|
+
input: ['text', 'image'],
|
|
10
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
11
|
+
contextWindow: 128_000,
|
|
12
|
+
maxTokens: 32_768,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function describeSelection(selection) {
|
|
16
|
+
return `${selection.model} (${selection.apiStyle}) @ ${selection.endpoint}`;
|
|
17
|
+
}
|