@stevezhou/sisu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +6 -0
- package/README.md +49 -0
- package/dist/client.js +15 -0
- package/dist/commands.js +343 -0
- package/dist/http.js +54 -0
- package/dist/logo.js +33 -0
- package/dist/main.js +170 -0
- package/dist/mobius.js +246 -0
- package/dist/pager/app.js +420 -0
- package/dist/pager/history.js +48 -0
- package/dist/pager/input.js +83 -0
- package/dist/pager/model.js +211 -0
- package/dist/pager/render.js +147 -0
- package/dist/pager/stdio.js +42 -0
- package/dist/pager/theme.js +39 -0
- package/dist/sse.js +81 -0
- package/dist/store.js +144 -0
- package/dist/toolSummary.js +95 -0
- package/dist/transport.js +154 -0
- package/dist/tui.js +287 -0
- package/package.json +47 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.entriesFromMessages = entriesFromMessages;
|
|
4
|
+
const toolSummary_1 = require("../toolSummary");
|
|
5
|
+
const TOOL_BLOCKS = new Set(['tool_use', 'tool_status', 'tool_result', 'tool_start', 'tool_end']);
|
|
6
|
+
const TOOL_TYPES = new Set(['tool_use', 'tool_result']);
|
|
7
|
+
function lineCount(text) {
|
|
8
|
+
if (!text)
|
|
9
|
+
return 0;
|
|
10
|
+
return text.split('\n').length;
|
|
11
|
+
}
|
|
12
|
+
function isToolBlock(block) {
|
|
13
|
+
const type = String(block.type || '');
|
|
14
|
+
if (TOOL_BLOCKS.has(type))
|
|
15
|
+
return true;
|
|
16
|
+
return !type && Boolean(block.tool || block.name);
|
|
17
|
+
}
|
|
18
|
+
function toolBlockText(block) {
|
|
19
|
+
return (0, toolSummary_1.summarizePersistedTool)(block);
|
|
20
|
+
}
|
|
21
|
+
function asEntry(kind, text) {
|
|
22
|
+
return { kind, text, folded: kind === 'tool' && lineCount(text) > 8 };
|
|
23
|
+
}
|
|
24
|
+
function entriesFromMessages(messages, limit = 200) {
|
|
25
|
+
const raw = [];
|
|
26
|
+
for (const msg of messages) {
|
|
27
|
+
const blocks = (msg.content_blocks || []).filter(isToolBlock);
|
|
28
|
+
for (const block of blocks)
|
|
29
|
+
raw.push(asEntry('tool', toolBlockText(block)));
|
|
30
|
+
const content = (msg.content || '').trim();
|
|
31
|
+
const isToolMsg = TOOL_TYPES.has(String(msg.message_type || ''));
|
|
32
|
+
if (isToolMsg && blocks.length === 0 && content) {
|
|
33
|
+
raw.push(asEntry('tool', content));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (isToolMsg)
|
|
37
|
+
continue;
|
|
38
|
+
if (!content)
|
|
39
|
+
continue;
|
|
40
|
+
if (msg.role !== 'user' && msg.role !== 'assistant')
|
|
41
|
+
continue;
|
|
42
|
+
if (blocks.length > 0 && content === toolBlockText(blocks[0]))
|
|
43
|
+
continue;
|
|
44
|
+
raw.push(asEntry(msg.role, content));
|
|
45
|
+
}
|
|
46
|
+
const truncated = raw.length > limit;
|
|
47
|
+
return { entries: truncated ? raw.slice(-limit) : raw, truncated };
|
|
48
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decodeKeys = decodeKeys;
|
|
4
|
+
/**
|
|
5
|
+
* Decode a raw terminal input chunk into pager keys.
|
|
6
|
+
* Incomplete CSI sequences are returned in `rest` so the next read can finish them.
|
|
7
|
+
* `\x03` (Ctrl+C) maps to escape; the app treats escape at empty draft as quit.
|
|
8
|
+
*/
|
|
9
|
+
function decodeKeys(chunk) {
|
|
10
|
+
const keys = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < chunk.length) {
|
|
13
|
+
const ch = chunk[i];
|
|
14
|
+
if (ch === '\x1b') {
|
|
15
|
+
// Incomplete CSI prefix — hold for the next chunk.
|
|
16
|
+
if (i + 1 === chunk.length) {
|
|
17
|
+
keys.push({ type: 'escape' });
|
|
18
|
+
i += 1;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (chunk[i + 1] === '[') {
|
|
22
|
+
if (i + 2 >= chunk.length) {
|
|
23
|
+
return { keys, rest: chunk.slice(i) };
|
|
24
|
+
}
|
|
25
|
+
const code = chunk[i + 2];
|
|
26
|
+
const arrow = code === 'A'
|
|
27
|
+
? 'up'
|
|
28
|
+
: code === 'B'
|
|
29
|
+
? 'down'
|
|
30
|
+
: code === 'C'
|
|
31
|
+
? 'right'
|
|
32
|
+
: code === 'D'
|
|
33
|
+
? 'left'
|
|
34
|
+
: null;
|
|
35
|
+
if (arrow) {
|
|
36
|
+
keys.push({ type: arrow });
|
|
37
|
+
i += 3;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// Unknown completed CSI: treat ESC as escape and rescan from '['.
|
|
41
|
+
keys.push({ type: 'escape' });
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// ESC not introducing CSI → escape
|
|
46
|
+
keys.push({ type: 'escape' });
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ch === '\r' || ch === '\n') {
|
|
51
|
+
keys.push({ type: 'enter' });
|
|
52
|
+
i += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
56
|
+
keys.push({ type: 'backspace' });
|
|
57
|
+
i += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Ctrl+C → escape (app treats escape at empty draft as quit)
|
|
61
|
+
if (ch === '\x03') {
|
|
62
|
+
keys.push({ type: 'escape' });
|
|
63
|
+
i += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Skip other C0 controls (except those handled above)
|
|
67
|
+
const code = ch.charCodeAt(0);
|
|
68
|
+
if (code < 0x20 || code === 0x7f) {
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Printable UTF-16 code unit / surrogate pair as one char
|
|
73
|
+
const cp = chunk.codePointAt(i);
|
|
74
|
+
if (cp === undefined) {
|
|
75
|
+
i += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const value = String.fromCodePoint(cp);
|
|
79
|
+
keys.push({ type: 'char', value });
|
|
80
|
+
i += value.length;
|
|
81
|
+
}
|
|
82
|
+
return { keys, rest: '' };
|
|
83
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SLASH_COMMANDS = void 0;
|
|
4
|
+
exports.createPagerState = createPagerState;
|
|
5
|
+
exports.filterSlash = filterSlash;
|
|
6
|
+
exports.startAssistant = startAssistant;
|
|
7
|
+
exports.insertToolBeforeLiveAssistant = insertToolBeforeLiveAssistant;
|
|
8
|
+
exports.appendText = appendText;
|
|
9
|
+
exports.applyKey = applyKey;
|
|
10
|
+
exports.SLASH_COMMANDS = [
|
|
11
|
+
{ name: '/new', hint: 'Start a new conversation (alias: /clear)' },
|
|
12
|
+
{ name: '/resume', hint: 'Resume a conversation (alias: /history)' },
|
|
13
|
+
{ name: '/status', hint: 'Show session status' },
|
|
14
|
+
{ name: '/ls', hint: 'List local workspace files' },
|
|
15
|
+
{ name: '/training', hint: 'Training mode' },
|
|
16
|
+
{ name: '/theme', hint: 'Toggle theme' },
|
|
17
|
+
{ name: '/help', hint: 'Show help' },
|
|
18
|
+
{ name: '/quit', hint: 'Quit the pager (alias: /exit)' },
|
|
19
|
+
];
|
|
20
|
+
/** Alias token → primary slash command name. */
|
|
21
|
+
const SLASH_ALIASES = {
|
|
22
|
+
'/clear': '/new',
|
|
23
|
+
'/history': '/resume',
|
|
24
|
+
'/exit': '/quit',
|
|
25
|
+
};
|
|
26
|
+
let nextEntryId = 1;
|
|
27
|
+
function entryId() {
|
|
28
|
+
const id = `e${nextEntryId}`;
|
|
29
|
+
nextEntryId += 1;
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
function createPagerState() {
|
|
33
|
+
return {
|
|
34
|
+
entries: [],
|
|
35
|
+
selected: 0,
|
|
36
|
+
draft: '',
|
|
37
|
+
slashOpen: false,
|
|
38
|
+
conversationId: '',
|
|
39
|
+
slashIndex: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function clampSelected(entries, selected) {
|
|
43
|
+
if (entries.length === 0)
|
|
44
|
+
return 0;
|
|
45
|
+
if (selected < 0)
|
|
46
|
+
return 0;
|
|
47
|
+
if (selected >= entries.length)
|
|
48
|
+
return entries.length - 1;
|
|
49
|
+
return selected;
|
|
50
|
+
}
|
|
51
|
+
function clampSlashIndex(draft, slashIndex) {
|
|
52
|
+
const items = filterSlash(draft);
|
|
53
|
+
if (items.length === 0)
|
|
54
|
+
return 0;
|
|
55
|
+
if (slashIndex < 0)
|
|
56
|
+
return items.length - 1;
|
|
57
|
+
if (slashIndex >= items.length)
|
|
58
|
+
return 0;
|
|
59
|
+
return slashIndex;
|
|
60
|
+
}
|
|
61
|
+
function withDraft(state, draft, slashOpen) {
|
|
62
|
+
const open = slashOpen ?? (draft.startsWith('/') ? state.slashOpen || draft === '/' : false);
|
|
63
|
+
return {
|
|
64
|
+
...state,
|
|
65
|
+
draft,
|
|
66
|
+
slashOpen: open && draft.startsWith('/'),
|
|
67
|
+
slashIndex: open && draft.startsWith('/') ? clampSlashIndex(draft, state.slashIndex) : 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function setEntryFold(state, folded) {
|
|
71
|
+
if (state.entries.length === 0)
|
|
72
|
+
return state;
|
|
73
|
+
const selected = clampSelected(state.entries, state.selected);
|
|
74
|
+
const entries = state.entries.map((entry, index) => index === selected ? { ...entry, folded } : entry);
|
|
75
|
+
return { ...state, entries, selected };
|
|
76
|
+
}
|
|
77
|
+
function filterSlash(draft) {
|
|
78
|
+
const query = draft.trim().toLowerCase();
|
|
79
|
+
if (!query.startsWith('/'))
|
|
80
|
+
return [];
|
|
81
|
+
const matched = new Map();
|
|
82
|
+
for (const cmd of exports.SLASH_COMMANDS) {
|
|
83
|
+
if (cmd.name.startsWith(query) || query.startsWith(cmd.name)) {
|
|
84
|
+
matched.set(cmd.name, cmd);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const [alias, primary] of Object.entries(SLASH_ALIASES)) {
|
|
88
|
+
if (alias.startsWith(query) || query.startsWith(alias)) {
|
|
89
|
+
const cmd = exports.SLASH_COMMANDS.find((item) => item.name === primary);
|
|
90
|
+
if (cmd)
|
|
91
|
+
matched.set(cmd.name, cmd);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Preserve canonical order
|
|
95
|
+
return exports.SLASH_COMMANDS.filter((cmd) => matched.has(cmd.name));
|
|
96
|
+
}
|
|
97
|
+
function startAssistant(state) {
|
|
98
|
+
const entry = {
|
|
99
|
+
id: entryId(),
|
|
100
|
+
kind: 'assistant',
|
|
101
|
+
text: '',
|
|
102
|
+
folded: false,
|
|
103
|
+
};
|
|
104
|
+
const entries = [...state.entries, entry];
|
|
105
|
+
return {
|
|
106
|
+
...state,
|
|
107
|
+
entries,
|
|
108
|
+
selected: entries.length - 1,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Insert a tool card before the live (last) assistant so the answer stays the turn tail. */
|
|
112
|
+
function insertToolBeforeLiveAssistant(state, entry) {
|
|
113
|
+
let live = -1;
|
|
114
|
+
for (let i = state.entries.length - 1; i >= 0; i -= 1) {
|
|
115
|
+
if (state.entries[i].kind === 'assistant') {
|
|
116
|
+
live = i;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (live < 0) {
|
|
121
|
+
const entries = [...state.entries, entry];
|
|
122
|
+
return { ...state, entries, selected: entries.length - 1 };
|
|
123
|
+
}
|
|
124
|
+
const entries = [...state.entries.slice(0, live), entry, ...state.entries.slice(live)];
|
|
125
|
+
return { ...state, entries, selected: live + 1 };
|
|
126
|
+
}
|
|
127
|
+
function appendText(state, text) {
|
|
128
|
+
if (!text)
|
|
129
|
+
return state;
|
|
130
|
+
// Append onto the last assistant entry (the live stream target).
|
|
131
|
+
for (let i = state.entries.length - 1; i >= 0; i -= 1) {
|
|
132
|
+
if (state.entries[i].kind === 'assistant') {
|
|
133
|
+
const entries = state.entries.slice();
|
|
134
|
+
entries[i] = { ...entries[i], text: entries[i].text + text };
|
|
135
|
+
return { ...state, entries };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// No assistant yet — create one and seed it.
|
|
139
|
+
const seeded = startAssistant(state);
|
|
140
|
+
return appendText(seeded, text);
|
|
141
|
+
}
|
|
142
|
+
function applyKey(state, key) {
|
|
143
|
+
switch (key.type) {
|
|
144
|
+
case 'char': {
|
|
145
|
+
const draft = state.draft + key.value;
|
|
146
|
+
if (key.value === '/' && state.draft === '') {
|
|
147
|
+
return { ...state, draft: '/', slashOpen: true, slashIndex: 0 };
|
|
148
|
+
}
|
|
149
|
+
if (state.slashOpen || draft.startsWith('/')) {
|
|
150
|
+
return withDraft(state, draft, draft.startsWith('/'));
|
|
151
|
+
}
|
|
152
|
+
return { ...state, draft };
|
|
153
|
+
}
|
|
154
|
+
case 'backspace': {
|
|
155
|
+
if (!state.draft)
|
|
156
|
+
return state;
|
|
157
|
+
const draft = state.draft.slice(0, -1);
|
|
158
|
+
if (state.slashOpen) {
|
|
159
|
+
if (!draft.startsWith('/')) {
|
|
160
|
+
return { ...state, draft, slashOpen: false, slashIndex: 0 };
|
|
161
|
+
}
|
|
162
|
+
return withDraft(state, draft, true);
|
|
163
|
+
}
|
|
164
|
+
return { ...state, draft };
|
|
165
|
+
}
|
|
166
|
+
case 'escape': {
|
|
167
|
+
if (state.slashOpen) {
|
|
168
|
+
return { ...state, slashOpen: false, slashIndex: 0 };
|
|
169
|
+
}
|
|
170
|
+
return state;
|
|
171
|
+
}
|
|
172
|
+
case 'enter': {
|
|
173
|
+
// Command execution is owned by the app layer; model only retains draft.
|
|
174
|
+
return state;
|
|
175
|
+
}
|
|
176
|
+
case 'left':
|
|
177
|
+
return setEntryFold(state, true);
|
|
178
|
+
case 'right':
|
|
179
|
+
return setEntryFold(state, false);
|
|
180
|
+
case 'up': {
|
|
181
|
+
if (state.slashOpen) {
|
|
182
|
+
const items = filterSlash(state.draft);
|
|
183
|
+
if (items.length === 0)
|
|
184
|
+
return state;
|
|
185
|
+
const slashIndex = (state.slashIndex - 1 + items.length) % items.length;
|
|
186
|
+
return { ...state, slashIndex };
|
|
187
|
+
}
|
|
188
|
+
if (state.draft === '' && state.entries.length > 0) {
|
|
189
|
+
const selected = clampSelected(state.entries, state.selected - 1);
|
|
190
|
+
return { ...state, selected };
|
|
191
|
+
}
|
|
192
|
+
return state;
|
|
193
|
+
}
|
|
194
|
+
case 'down': {
|
|
195
|
+
if (state.slashOpen) {
|
|
196
|
+
const items = filterSlash(state.draft);
|
|
197
|
+
if (items.length === 0)
|
|
198
|
+
return state;
|
|
199
|
+
const slashIndex = (state.slashIndex + 1) % items.length;
|
|
200
|
+
return { ...state, slashIndex };
|
|
201
|
+
}
|
|
202
|
+
if (state.draft === '' && state.entries.length > 0) {
|
|
203
|
+
const selected = clampSelected(state.entries, state.selected + 1);
|
|
204
|
+
return { ...state, selected };
|
|
205
|
+
}
|
|
206
|
+
return state;
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
return state;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderPager = renderPager;
|
|
4
|
+
const model_1 = require("./model");
|
|
5
|
+
const theme_1 = require("./theme");
|
|
6
|
+
const PROMPT_PREFIX = '› ';
|
|
7
|
+
const PROMPT_BOX_ROWS = 2;
|
|
8
|
+
const STATUS_ROWS = 1;
|
|
9
|
+
const MARK_WIDTH = 2;
|
|
10
|
+
/** Pad (and clip reserved chrome) to exactly `cols` cells. */
|
|
11
|
+
function pad(line, cols) {
|
|
12
|
+
const width = Math.max(0, cols);
|
|
13
|
+
if (width === 0)
|
|
14
|
+
return '';
|
|
15
|
+
const plain = (0, theme_1.stripAnsi)(line);
|
|
16
|
+
if (plain.length === width)
|
|
17
|
+
return plain;
|
|
18
|
+
if (plain.length > width)
|
|
19
|
+
return plain.slice(0, width);
|
|
20
|
+
return plain.padEnd(width, ' ');
|
|
21
|
+
}
|
|
22
|
+
function wrapPlain(text, width) {
|
|
23
|
+
const max = Math.max(1, width);
|
|
24
|
+
if (!text)
|
|
25
|
+
return [''];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const paragraph of text.split('\n')) {
|
|
28
|
+
if (!paragraph) {
|
|
29
|
+
out.push('');
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (let i = 0; i < paragraph.length; i += max) {
|
|
33
|
+
out.push(paragraph.slice(i, i + max));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function lineCount(text) {
|
|
39
|
+
if (!text)
|
|
40
|
+
return 0;
|
|
41
|
+
return text.split('\n').length;
|
|
42
|
+
}
|
|
43
|
+
function entryBodyLines(entry, wrapWidth) {
|
|
44
|
+
if (entry.folded) {
|
|
45
|
+
const n = lineCount(entry.text);
|
|
46
|
+
const unit = n === 1 ? 'line' : 'lines';
|
|
47
|
+
return wrapPlain(`${entry.kind} · ${n} ${unit}`, wrapWidth);
|
|
48
|
+
}
|
|
49
|
+
if (!entry.text)
|
|
50
|
+
return [''];
|
|
51
|
+
return wrapPlain(entry.text, wrapWidth);
|
|
52
|
+
}
|
|
53
|
+
function layoutScrollback(state, cols) {
|
|
54
|
+
const wrapWidth = Math.max(1, cols - MARK_WIDTH);
|
|
55
|
+
const lines = [];
|
|
56
|
+
const entryOf = [];
|
|
57
|
+
for (let i = 0; i < state.entries.length; i += 1) {
|
|
58
|
+
const entry = state.entries[i];
|
|
59
|
+
const body = entryBodyLines(entry, wrapWidth);
|
|
60
|
+
const mark = i === state.selected && state.entries.length > 0 ? '▸ ' : ' ';
|
|
61
|
+
for (let j = 0; j < body.length; j += 1) {
|
|
62
|
+
lines.push(j === 0 ? `${mark}${body[j]}` : ` ${body[j]}`);
|
|
63
|
+
entryOf.push(i);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { lines, entryOf };
|
|
67
|
+
}
|
|
68
|
+
function windowAroundSelected(lines, entryOf, selected, budget, lastEntry) {
|
|
69
|
+
if (budget <= 0)
|
|
70
|
+
return [];
|
|
71
|
+
if (lines.length <= budget)
|
|
72
|
+
return lines;
|
|
73
|
+
if (selected >= lastEntry)
|
|
74
|
+
return lines.slice(lines.length - budget);
|
|
75
|
+
let start = -1;
|
|
76
|
+
let end = -1;
|
|
77
|
+
for (let i = 0; i < entryOf.length; i += 1) {
|
|
78
|
+
if (entryOf[i] !== selected)
|
|
79
|
+
continue;
|
|
80
|
+
if (start < 0)
|
|
81
|
+
start = i;
|
|
82
|
+
end = i;
|
|
83
|
+
}
|
|
84
|
+
if (start < 0)
|
|
85
|
+
return lines.slice(lines.length - budget);
|
|
86
|
+
if (end - start + 1 >= budget)
|
|
87
|
+
return lines.slice(start, start + budget);
|
|
88
|
+
const windowStart = Math.max(0, Math.min(start, end + 1 - budget));
|
|
89
|
+
return lines.slice(windowStart, windowStart + budget);
|
|
90
|
+
}
|
|
91
|
+
function slashMenuLines(state) {
|
|
92
|
+
if (!state.slashOpen)
|
|
93
|
+
return [];
|
|
94
|
+
const items = (0, model_1.filterSlash)(state.draft);
|
|
95
|
+
return items.map((item, index) => {
|
|
96
|
+
const mark = index === state.slashIndex ? '› ' : ' ';
|
|
97
|
+
return `${mark}${item.name} ${item.hint}`;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Pure fixed-grid frame: always `rows` lines, each exactly `cols` characters.
|
|
102
|
+
* `theme` selects the SiSu palette via `getTheme` for future colored paint; the
|
|
103
|
+
* frame itself is plain so `line.length === cols` for the tty writer.
|
|
104
|
+
* Deterministic: no clock, no I/O.
|
|
105
|
+
*/
|
|
106
|
+
function renderPager(state, cols, rows, theme = 'dark') {
|
|
107
|
+
// Bind palette so dark/light stays on the public pure path (app may recolor).
|
|
108
|
+
(0, theme_1.getTheme)(theme);
|
|
109
|
+
const height = Math.max(0, rows);
|
|
110
|
+
const width = Math.max(0, cols);
|
|
111
|
+
if (height === 0)
|
|
112
|
+
return '';
|
|
113
|
+
const promptRows = Math.min(PROMPT_BOX_ROWS, height);
|
|
114
|
+
const statusRows = height > promptRows ? Math.min(STATUS_ROWS, height - promptRows) : 0;
|
|
115
|
+
const chrome = promptRows + statusRows;
|
|
116
|
+
const bodyBudget = Math.max(0, height - chrome);
|
|
117
|
+
const slash = slashMenuLines(state);
|
|
118
|
+
const slashTake = Math.min(slash.length, bodyBudget);
|
|
119
|
+
const scrollBudget = bodyBudget - slashTake;
|
|
120
|
+
const laid = layoutScrollback(state, width);
|
|
121
|
+
const lastEntry = Math.max(0, state.entries.length - 1);
|
|
122
|
+
const visibleScroll = windowAroundSelected(laid.lines, laid.entryOf, state.selected, scrollBudget, lastEntry);
|
|
123
|
+
const body = [];
|
|
124
|
+
// Top-pad scrollback so newest content sits just above slash/status/prompt.
|
|
125
|
+
while (body.length + visibleScroll.length < scrollBudget) {
|
|
126
|
+
body.push('');
|
|
127
|
+
}
|
|
128
|
+
body.push(...visibleScroll);
|
|
129
|
+
body.push(...slash.slice(0, slashTake));
|
|
130
|
+
const lines = body.map((line) => pad(line, width));
|
|
131
|
+
if (statusRows > 0) {
|
|
132
|
+
lines.push(pad(state.statusLine ?? '', width));
|
|
133
|
+
}
|
|
134
|
+
if (promptRows >= 2) {
|
|
135
|
+
// Prompt box: border row + draft row (`› {draft}`).
|
|
136
|
+
lines.push(pad('─'.repeat(width), width));
|
|
137
|
+
lines.push(pad(`${PROMPT_PREFIX}${state.draft}`, width));
|
|
138
|
+
}
|
|
139
|
+
else if (promptRows === 1) {
|
|
140
|
+
lines.push(pad(`${PROMPT_PREFIX}${state.draft}`, width));
|
|
141
|
+
}
|
|
142
|
+
while (lines.length < height)
|
|
143
|
+
lines.push(pad('', width));
|
|
144
|
+
if (lines.length > height)
|
|
145
|
+
lines.length = height;
|
|
146
|
+
return lines.map((line) => pad(line, width)).join('\n');
|
|
147
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stdioPagerIo = stdioPagerIo;
|
|
4
|
+
function stdioPagerIo() {
|
|
5
|
+
const stdin = process.stdin;
|
|
6
|
+
const stdout = process.stdout;
|
|
7
|
+
let previousRaw = false;
|
|
8
|
+
return {
|
|
9
|
+
write(text) {
|
|
10
|
+
stdout.write(text);
|
|
11
|
+
},
|
|
12
|
+
onData(handler) {
|
|
13
|
+
const listener = (chunk) => {
|
|
14
|
+
handler(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
|
15
|
+
};
|
|
16
|
+
stdin.on('data', listener);
|
|
17
|
+
return () => {
|
|
18
|
+
stdin.off('data', listener);
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
enterRaw() {
|
|
22
|
+
if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
|
|
23
|
+
previousRaw = Boolean(stdin.isRaw);
|
|
24
|
+
stdin.setRawMode(true);
|
|
25
|
+
}
|
|
26
|
+
stdin.setEncoding('utf8');
|
|
27
|
+
stdin.resume();
|
|
28
|
+
},
|
|
29
|
+
leaveRaw() {
|
|
30
|
+
if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
|
|
31
|
+
stdin.setRawMode(previousRaw);
|
|
32
|
+
}
|
|
33
|
+
stdin.pause();
|
|
34
|
+
},
|
|
35
|
+
get columns() {
|
|
36
|
+
return stdout.columns || 80;
|
|
37
|
+
},
|
|
38
|
+
get rows() {
|
|
39
|
+
return stdout.rows || 24;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** SiSu pager palette: blue → purple → gold (same anchors as mobiusRgb). */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.getTheme = getTheme;
|
|
5
|
+
exports.stripAnsi = stripAnsi;
|
|
6
|
+
const RESET = '\x1b[0m';
|
|
7
|
+
function ansiRgb(r, g, b) {
|
|
8
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
9
|
+
}
|
|
10
|
+
function paint(prefix) {
|
|
11
|
+
return (s) => (s ? `${prefix}${s}${RESET}` : s);
|
|
12
|
+
}
|
|
13
|
+
/** Brand anchors from mobiusRgb (blue, purple, gold). */
|
|
14
|
+
const BLUE = ansiRgb(37, 99, 235);
|
|
15
|
+
const PURPLE = ansiRgb(124, 58, 237);
|
|
16
|
+
const GOLD = ansiRgb(217, 119, 6);
|
|
17
|
+
const DARK = {
|
|
18
|
+
text: paint(ansiRgb(230, 232, 240)),
|
|
19
|
+
dim: paint(ansiRgb(120, 126, 150)),
|
|
20
|
+
accent: paint(GOLD),
|
|
21
|
+
error: paint(ansiRgb(239, 68, 68)),
|
|
22
|
+
border: paint(PURPLE),
|
|
23
|
+
reset: RESET,
|
|
24
|
+
};
|
|
25
|
+
const LIGHT = {
|
|
26
|
+
text: paint(ansiRgb(24, 28, 40)),
|
|
27
|
+
dim: paint(ansiRgb(100, 108, 128)),
|
|
28
|
+
accent: paint(GOLD),
|
|
29
|
+
error: paint(ansiRgb(185, 28, 28)),
|
|
30
|
+
border: paint(BLUE),
|
|
31
|
+
reset: RESET,
|
|
32
|
+
};
|
|
33
|
+
function getTheme(name = 'dark') {
|
|
34
|
+
return name === 'light' ? LIGHT : DARK;
|
|
35
|
+
}
|
|
36
|
+
/** Strip 24-bit / SGR sequences for visible-width measurement. */
|
|
37
|
+
function stripAnsi(s) {
|
|
38
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
39
|
+
}
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.consumeSse = consumeSse;
|
|
4
|
+
exports.sseEventText = sseEventText;
|
|
5
|
+
exports.extractSseText = extractSseText;
|
|
6
|
+
function parseSseData(raw) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return raw;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function classifyEvent(name) {
|
|
15
|
+
if (name === 'text')
|
|
16
|
+
return 'text';
|
|
17
|
+
if (name === 'error')
|
|
18
|
+
return 'error';
|
|
19
|
+
return 'other';
|
|
20
|
+
}
|
|
21
|
+
function parseSegment(segment) {
|
|
22
|
+
if (!segment.trim())
|
|
23
|
+
return null;
|
|
24
|
+
const eventMatch = segment.match(/^event: (.+)$/m);
|
|
25
|
+
const dataMatch = segment.match(/^data: (.+)$/m);
|
|
26
|
+
if (!eventMatch || !dataMatch)
|
|
27
|
+
return null;
|
|
28
|
+
const name = eventMatch[1].trim();
|
|
29
|
+
return {
|
|
30
|
+
type: classifyEvent(name),
|
|
31
|
+
name,
|
|
32
|
+
data: parseSseData(dataMatch[1]),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function consumeSse(buffer) {
|
|
36
|
+
const events = [];
|
|
37
|
+
const segments = buffer.split('\n\n');
|
|
38
|
+
const rest = segments.pop() ?? '';
|
|
39
|
+
for (const segment of segments) {
|
|
40
|
+
const event = parseSegment(segment);
|
|
41
|
+
if (event)
|
|
42
|
+
events.push(event);
|
|
43
|
+
}
|
|
44
|
+
return { events, rest };
|
|
45
|
+
}
|
|
46
|
+
function sseEventText(event) {
|
|
47
|
+
if (event.type === 'error') {
|
|
48
|
+
const message = typeof event.data === 'string'
|
|
49
|
+
? event.data
|
|
50
|
+
: event.data?.message || 'stream error';
|
|
51
|
+
throw new Error(String(message));
|
|
52
|
+
}
|
|
53
|
+
if (event.type !== 'text')
|
|
54
|
+
return '';
|
|
55
|
+
const data = event.data;
|
|
56
|
+
if (typeof data === 'string')
|
|
57
|
+
return data;
|
|
58
|
+
if (data && typeof data === 'object') {
|
|
59
|
+
const record = data;
|
|
60
|
+
const piece = record.text ?? record.content ?? record.delta;
|
|
61
|
+
if (typeof piece === 'string')
|
|
62
|
+
return piece;
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
function extractSseText(stream) {
|
|
67
|
+
const { events } = consumeSse(stream.endsWith('\n\n') ? stream : `${stream}\n\n`);
|
|
68
|
+
const chunks = [];
|
|
69
|
+
for (const event of events) {
|
|
70
|
+
if (event.type === 'error') {
|
|
71
|
+
// sseEventText throws on error
|
|
72
|
+
sseEventText(event);
|
|
73
|
+
}
|
|
74
|
+
if (event.type === 'text') {
|
|
75
|
+
const piece = sseEventText(event);
|
|
76
|
+
if (piece)
|
|
77
|
+
chunks.push(piece);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return chunks.join('');
|
|
81
|
+
}
|