@researchcomputer/pista 0.1.2 → 0.1.3

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.
@@ -3,7 +3,7 @@ import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  export const Scrollbar = React.memo(function Scrollbar({ trackHeight, totalEntries, visibleEntries, offset, }) {
5
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))) }));
6
+ return (_jsx(Box, { flexDirection: "column", width: 2, children: Array.from({ length: trackHeight }, (_, i) => (_jsx(Text, { dimColor: true, children: ' ' }, i))) }));
7
7
  }
8
8
  const thumbSize = Math.max(1, Math.round((visibleEntries / totalEntries) * trackHeight));
9
9
  const maxOffset = totalEntries - visibleEntries;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+ export const StatusBar = React.memo(function StatusBar({ running, status, spinnerFrame, btwActive = false, }) {
6
+ return (_jsxs(Box, { paddingX: 1, justifyContent: "space-between", marginBottom: 0, children: [_jsx(Text, { dimColor: true, children: running ? 'Ctrl+C abort' : 'Ctrl+C exit' }), _jsx(Box, { children: running ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "cyanBright", children: SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] }), _jsxs(Text, { color: btwActive ? 'magentaBright' : 'yellowBright', children: [btwActive ? ' btw · ' : ' ', status] })] })) : btwActive ? (_jsx(Text, { color: "magentaBright", children: "\u25CF btw" })) : (_jsx(Text, { color: "greenBright", children: "\u25CF ready" })) })] }));
7
+ });
@@ -0,0 +1,89 @@
1
+ import { useCallback, useState } from 'react';
2
+ export function useComposer() {
3
+ const [composer, setComposer] = useState({ value: '', cursor: 0 });
4
+ const [history, setHistory] = useState([]);
5
+ const [historyIndex, setHistoryIndex] = useState(null);
6
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
7
+ const [_draftBeforeHistory, setDraftBeforeHistory] = useState(null);
8
+ const remember = useCallback((value) => {
9
+ setHistory((current) => {
10
+ const trimmed = value.trim();
11
+ if (!trimmed)
12
+ return current;
13
+ const deduped = current.filter((entry) => entry !== value);
14
+ return [...deduped, value].slice(-50);
15
+ });
16
+ }, []);
17
+ const clear = useCallback(() => {
18
+ setComposer({ value: '', cursor: 0 });
19
+ setHistoryIndex(null);
20
+ setDraftBeforeHistory(null);
21
+ }, []);
22
+ const navigateHistory = useCallback((direction) => {
23
+ if (direction === 'up') {
24
+ setHistory((currentHistory) => {
25
+ if (currentHistory.length === 0)
26
+ return currentHistory;
27
+ setHistoryIndex((current) => {
28
+ const nextIndex = current === null ? currentHistory.length - 1 : Math.max(0, current - 1);
29
+ setDraftBeforeHistory((draft) => draft ?? composer);
30
+ const nextValue = currentHistory[nextIndex] ?? '';
31
+ setComposer({ value: nextValue, cursor: nextValue.length });
32
+ return nextIndex;
33
+ });
34
+ return currentHistory;
35
+ });
36
+ }
37
+ else {
38
+ setHistory((currentHistory) => {
39
+ if (currentHistory.length === 0)
40
+ return currentHistory;
41
+ setHistoryIndex((currentIndex) => {
42
+ if (currentIndex === null)
43
+ return currentIndex;
44
+ if (currentIndex >= currentHistory.length - 1) {
45
+ setDraftBeforeHistory((draft) => {
46
+ setComposer(draft ?? { value: '', cursor: 0 });
47
+ return null;
48
+ });
49
+ return null;
50
+ }
51
+ const nextIndex = currentIndex + 1;
52
+ const nextValue = currentHistory[nextIndex] ?? '';
53
+ setComposer({ value: nextValue, cursor: nextValue.length });
54
+ return nextIndex;
55
+ });
56
+ return currentHistory;
57
+ });
58
+ }
59
+ }, [composer]);
60
+ const insertNewline = useCallback(() => {
61
+ setComposer((current) => {
62
+ const nextValue = `${current.value.slice(0, current.cursor)}\n${current.value.slice(current.cursor)}`;
63
+ return { value: nextValue, cursor: current.cursor + 1 };
64
+ });
65
+ setHistoryIndex(null);
66
+ setDraftBeforeHistory(null);
67
+ }, []);
68
+ const applySelection = useCallback((insertText) => {
69
+ setComposer({ value: insertText, cursor: insertText.length });
70
+ setHistoryIndex(null);
71
+ setDraftBeforeHistory(null);
72
+ }, []);
73
+ const resetHistoryNavigation = useCallback(() => {
74
+ setHistoryIndex(null);
75
+ setDraftBeforeHistory(null);
76
+ }, []);
77
+ return {
78
+ composer,
79
+ setComposer,
80
+ history,
81
+ historyIndex,
82
+ remember,
83
+ clear,
84
+ navigateHistory,
85
+ insertNewline,
86
+ applySelection,
87
+ resetHistoryNavigation,
88
+ };
89
+ }
@@ -0,0 +1,24 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ test('composer history deduplicates entries', () => {
4
+ const current = ['hello', 'world'];
5
+ const value = 'hello';
6
+ const deduped = current.filter((entry) => entry !== value);
7
+ const result = [...deduped, value].slice(-50);
8
+ assert.deepEqual(result, ['world', 'hello']);
9
+ });
10
+ test('composer history caps at 50 entries', () => {
11
+ const current = Array.from({ length: 50 }, (_, i) => `entry-${i}`);
12
+ const value = 'new-entry';
13
+ const deduped = current.filter((entry) => entry !== value);
14
+ const result = [...deduped, value].slice(-50);
15
+ assert.equal(result.length, 50);
16
+ assert.equal(result[49], 'new-entry');
17
+ assert.equal(result[0], 'entry-1');
18
+ });
19
+ test('empty or whitespace-only values are not added to history', () => {
20
+ const values = ['', ' ', '\n', '\t'];
21
+ for (const value of values) {
22
+ assert.ok(!value.trim(), `"${value}" should be empty after trim`);
23
+ }
24
+ });
@@ -0,0 +1,64 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ export function useLiveAssistant() {
3
+ const bufferRef = useRef('');
4
+ const flushTimerRef = useRef(null);
5
+ const mountedRef = useRef(true);
6
+ const [text, setText] = useState('');
7
+ const textRef = useRef(text);
8
+ textRef.current = text;
9
+ const flush = useCallback(() => {
10
+ if (flushTimerRef.current) {
11
+ clearTimeout(flushTimerRef.current);
12
+ flushTimerRef.current = null;
13
+ }
14
+ if (!mountedRef.current)
15
+ return;
16
+ const next = bufferRef.current;
17
+ setText((current) => (current === next ? current : next));
18
+ }, []);
19
+ const scheduleFlush = useCallback(() => {
20
+ if (flushTimerRef.current)
21
+ return;
22
+ flushTimerRef.current = setTimeout(() => {
23
+ flushTimerRef.current = null;
24
+ if (!mountedRef.current)
25
+ return;
26
+ const next = bufferRef.current;
27
+ setText((current) => (current === next ? current : next));
28
+ }, 33);
29
+ }, []);
30
+ const appendDelta = useCallback((delta) => {
31
+ if (!delta)
32
+ return;
33
+ bufferRef.current += delta;
34
+ scheduleFlush();
35
+ }, [scheduleFlush]);
36
+ const clear = useCallback(() => {
37
+ if (flushTimerRef.current) {
38
+ clearTimeout(flushTimerRef.current);
39
+ flushTimerRef.current = null;
40
+ }
41
+ bufferRef.current = '';
42
+ setText((current) => (current ? '' : current));
43
+ }, []);
44
+ const getText = useCallback(() => {
45
+ flush();
46
+ return bufferRef.current;
47
+ }, [flush]);
48
+ const cleanup = useCallback(() => {
49
+ mountedRef.current = false;
50
+ if (flushTimerRef.current) {
51
+ clearTimeout(flushTimerRef.current);
52
+ flushTimerRef.current = null;
53
+ }
54
+ }, []);
55
+ return useMemo(() => ({
56
+ get text() {
57
+ return textRef.current;
58
+ },
59
+ appendDelta,
60
+ clear,
61
+ getText,
62
+ cleanup,
63
+ }), [appendDelta, clear, getText, cleanup]);
64
+ }
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { PassThrough } from 'node:stream';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { useEffect, useState } from 'react';
7
+ import { render, Text } from 'ink';
8
+ import { useLiveAssistant } from './use-live-assistant.js';
9
+ test('useLiveAssistant keeps a stable object across unrelated rerenders', async () => {
10
+ let rerender;
11
+ let effectRuns = 0;
12
+ function Harness() {
13
+ const [count, setCount] = useState(0);
14
+ const liveAssistant = useLiveAssistant();
15
+ rerender = () => setCount((current) => current + 1);
16
+ useEffect(() => {
17
+ effectRuns += 1;
18
+ }, [liveAssistant]);
19
+ return _jsx(Text, { children: count });
20
+ }
21
+ const stdout = new PassThrough();
22
+ const stdin = new PassThrough();
23
+ const app = render(_jsx(Harness, {}), { stdout, stdin, stderr: stdout, exitOnCtrlC: false, patchConsole: false });
24
+ await delay(50);
25
+ rerender?.();
26
+ await delay(50);
27
+ app.unmount();
28
+ await app.waitUntilExit();
29
+ assert.equal(effectRuns, 1);
30
+ });
@@ -0,0 +1,21 @@
1
+ import { useState, useEffect } from 'react';
2
+ import process from 'node:process';
3
+ export function useTerminalSize() {
4
+ const [size, setSize] = useState({
5
+ columns: process.stdout.columns || 80,
6
+ rows: process.stdout.rows || 24,
7
+ });
8
+ useEffect(() => {
9
+ const onResize = () => {
10
+ setSize({
11
+ columns: process.stdout.columns || 80,
12
+ rows: process.stdout.rows || 24,
13
+ });
14
+ };
15
+ process.stdout.on('resize', onResize);
16
+ return () => {
17
+ process.stdout.off('resize', onResize);
18
+ };
19
+ }, []);
20
+ return size;
21
+ }
@@ -31,3 +31,25 @@ test('getTranscriptOffsetForEntry centers the requested entry when possible', ()
31
31
  assert.equal(getTranscriptOffsetForEntry(20, 5, 0), 15);
32
32
  assert.equal(getTranscriptOffsetForEntry(20, 5, 19), 0);
33
33
  });
34
+ test('getTranscriptWindow handles zero entries', () => {
35
+ const window = getTranscriptWindow(0, 10, 0);
36
+ assert.equal(window.start, 0);
37
+ assert.equal(window.end, 0);
38
+ assert.equal(window.offset, 0);
39
+ assert.equal(window.maxOffset, 0);
40
+ });
41
+ test('getTranscriptWindow handles more visible rows than entries', () => {
42
+ const window = getTranscriptWindow(3, 10, 0);
43
+ assert.equal(window.start, 0);
44
+ assert.equal(window.end, 3);
45
+ assert.equal(window.offset, 0);
46
+ });
47
+ test('stepTranscriptOffset clamps to bounds', () => {
48
+ const result = stepTranscriptOffset(0, 100, 5, 10);
49
+ assert.equal(result, 0);
50
+ });
51
+ test('getTranscriptOffsetForEntry centers the target entry', () => {
52
+ const offset = getTranscriptOffsetForEntry(20, 5, 10);
53
+ assert.ok(offset >= 0);
54
+ assert.ok(offset <= 15);
55
+ });
package/dist/utils.js CHANGED
@@ -86,9 +86,7 @@ export function mergeStoredSkills(base = [], override = []) {
86
86
  return orderedIds.map((id) => merged.get(id));
87
87
  }
88
88
  export function resolveConfiguredSkills(skills = []) {
89
- return skills
90
- .filter((skill) => skill.enabled !== false)
91
- .map(({ enabled: _enabled, ...skill }) => skill);
89
+ return skills.filter((skill) => skill.enabled !== false).map(({ enabled: _enabled, ...skill }) => skill);
92
90
  }
93
91
  export function truncate(value, maxLength) {
94
92
  if (value.length <= maxLength)
@@ -201,11 +199,31 @@ export async function fetchGitHubSkill(source) {
201
199
  */
202
200
  export async function fetchGitHubSkillIndex(owner, repo) {
203
201
  const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
204
- const response = await fetch(rawUrl);
202
+ let response;
203
+ try {
204
+ response = await fetch(rawUrl);
205
+ }
206
+ catch {
207
+ return null;
208
+ }
205
209
  if (!response.ok)
206
210
  return null;
207
- const data = await response.json();
208
- return data.plugins ?? null;
211
+ let data;
212
+ try {
213
+ data = await response.json();
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ if (!data || typeof data !== 'object')
219
+ return null;
220
+ const plugins = data.plugins;
221
+ if (!Array.isArray(plugins))
222
+ return null;
223
+ return plugins.filter((p) => typeof p === 'object' &&
224
+ p !== null &&
225
+ typeof p.name === 'string' &&
226
+ Array.isArray(p.skills));
209
227
  }
210
228
  export function normalizeEndpoint(value) {
211
229
  const parsed = new URL(value);
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { getCliMemoryDir, getCliPreferencesPath, getCliSessionsDir, loadResolvedPreferences, loadStoredPreferences, mergeStoredSkills, mergeStoredPreferences, normalizeAgentName, resolveConfiguredSkills, saveStoredPreferences, } from './utils.js';
6
+ import { errorMessage, getCliMemoryDir, getCliPreferencesPath, getCliSessionsDir, loadResolvedPreferences, loadStoredPreferences, logColor, mergeStoredSkills, mergeStoredPreferences, normalizeAgentName, parseSkillFrontmatter, resolveConfiguredSkills, saveStoredPreferences, stringifyPreview, } from './utils.js';
7
7
  test('CLI home-directory helpers resolve under the provided base directory', () => {
8
8
  const baseDir = '/tmp/pi-agent-home';
9
9
  assert.equal(getCliSessionsDir(baseDir), path.join(baseDir, '.pista', 'sessions'));
@@ -56,10 +56,12 @@ test('loadResolvedPreferences layers project preferences over global preferences
56
56
  selection: {
57
57
  model: 'project-model',
58
58
  },
59
- skills: [{
59
+ skills: [
60
+ {
60
61
  id: 'project-skill',
61
62
  promptSections: ['Prefer concise summaries.'],
62
- }],
63
+ },
64
+ ],
63
65
  }, projectDir);
64
66
  assert.deepEqual(loadResolvedPreferences(projectDir, homeDir), {
65
67
  selection: {
@@ -67,10 +69,12 @@ test('loadResolvedPreferences layers project preferences over global preferences
67
69
  endpoint: 'https://global.invalid/v1',
68
70
  apiStyle: 'chat',
69
71
  },
70
- skills: [{
72
+ skills: [
73
+ {
71
74
  id: 'project-skill',
72
75
  promptSections: ['Prefer concise summaries.'],
73
- }],
76
+ },
77
+ ],
74
78
  });
75
79
  });
76
80
  test('mergeStoredSkills replaces matching ids and preserves order', () => {
@@ -95,3 +99,47 @@ test('resolveConfiguredSkills filters disabled skills and strips CLI-only fields
95
99
  test('normalizeAgentName falls back to Pista when given blank input', () => {
96
100
  assert.equal(normalizeAgentName(' '), 'Pista');
97
101
  });
102
+ test('errorMessage extracts Error message', () => {
103
+ assert.equal(errorMessage(new Error('boom')), 'boom');
104
+ });
105
+ test('errorMessage stringifies non-Error values', () => {
106
+ assert.equal(errorMessage('oops'), 'oops');
107
+ assert.equal(errorMessage(42), '42');
108
+ assert.equal(errorMessage(null), 'null');
109
+ });
110
+ test('stringifyPreview handles undefined', () => {
111
+ assert.equal(stringifyPreview(undefined, 100), '[no content]');
112
+ });
113
+ test('stringifyPreview truncates long JSON', () => {
114
+ const result = stringifyPreview({ key: 'a'.repeat(200) }, 50);
115
+ assert.ok(result.length <= 50);
116
+ assert.ok(result.endsWith('...'));
117
+ });
118
+ test('logColor returns correct colors', () => {
119
+ assert.equal(logColor('system'), 'blueBright');
120
+ assert.equal(logColor('user'), 'greenBright');
121
+ assert.equal(logColor('assistant'), 'cyanBright');
122
+ assert.equal(logColor('tool'), 'yellowBright');
123
+ assert.equal(logColor('error'), 'redBright');
124
+ });
125
+ test('normalizeAgentName trims and caps length', () => {
126
+ assert.equal(normalizeAgentName(' My Agent '), 'My Agent');
127
+ assert.equal(normalizeAgentName('a'.repeat(30)), 'a'.repeat(24));
128
+ assert.equal(normalizeAgentName(''), 'Pista');
129
+ assert.equal(normalizeAgentName(' '), 'Pista');
130
+ });
131
+ test('parseSkillFrontmatter returns null for no frontmatter', () => {
132
+ assert.equal(parseSkillFrontmatter('just text'), null);
133
+ });
134
+ test('parseSkillFrontmatter parses valid frontmatter', () => {
135
+ const content = '---\nname: test-skill\ndescription: A test\n---\nBody content here';
136
+ const result = parseSkillFrontmatter(content);
137
+ assert.ok(result);
138
+ assert.equal(result.name, 'test-skill');
139
+ assert.equal(result.description, 'A test');
140
+ assert.equal(result.body, 'Body content here');
141
+ });
142
+ test('parseSkillFrontmatter returns null for missing name', () => {
143
+ const content = '---\ndescription: No name field\n---\nBody';
144
+ assert.equal(parseSkillFrontmatter(content), null);
145
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@researchcomputer/pista",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A lightweight CLI agent interface built with Ink",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,12 @@
13
13
  "build": "tsc -p tsconfig.json",
14
14
  "start": "node dist/index.js",
15
15
  "prepublishOnly": "npm run build",
16
- "test": "npm run build && node --test dist/*.test.js dist/**/*.test.js"
16
+ "test": "npm run build && node --test dist/*.test.js dist/**/*.test.js",
17
+ "lint": "eslint src/",
18
+ "lint:fix": "eslint src/ --fix",
19
+ "format": "prettier --write src/",
20
+ "format:check": "prettier --check src/",
21
+ "dev": "tsc -p tsconfig.json --watch"
17
22
  },
18
23
  "dependencies": {
19
24
  "@researchcomputer/agents-sdk": "^0.1.0",
@@ -22,8 +27,13 @@
22
27
  "react": "^19.2.4"
23
28
  },
24
29
  "devDependencies": {
30
+ "@eslint/js": "^10.0.1",
25
31
  "@types/react": "^19.2.14",
26
- "typescript": "^5.8.0"
32
+ "eslint": "^10.1.0",
33
+ "eslint-config-prettier": "^10.1.8",
34
+ "prettier": "^3.8.1",
35
+ "typescript": "^5.8.0",
36
+ "typescript-eslint": "^8.58.0"
27
37
  },
28
38
  "license": "MIT",
29
39
  "repository": {