subrouter-cli 0.0.0-stage → 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/CLAUDE.md +51 -0
- package/README.md +397 -2
- package/config.example.json +9 -0
- package/package.json +21 -4
- package/scripts/sse-harness.ts +252 -0
- package/scripts/sub-wrapper.sh +11 -0
- package/src/adapters/anthropic.ts +311 -0
- package/src/adapters/openai.ts +227 -0
- package/src/approval.ts +21 -0
- package/src/chatviewport.ts +123 -0
- package/src/client.ts +398 -0
- package/src/clipboard.ts +62 -0
- package/src/commandpolicy.ts +272 -0
- package/src/commands.ts +18 -0
- package/src/config.ts +107 -0
- package/src/effort.ts +18 -0
- package/src/images.ts +77 -0
- package/src/index.ts +248 -0
- package/src/lineinput.ts +726 -0
- package/src/loop.ts +201 -0
- package/src/markdown.ts +244 -0
- package/src/repl.ts +700 -0
- package/src/sessions.ts +58 -0
- package/src/sse.ts +64 -0
- package/src/terminal.ts +228 -0
- package/src/token.ts +36 -0
- package/src/toolpreview.ts +54 -0
- package/src/tools.ts +790 -0
- package/src/types.ts +73 -0
- package/src/ui.ts +813 -0
- package/src/usage.ts +186 -0
- package/test/absolute-tools.test.ts +296 -0
- package/test/absolute-ui.test.ts +153 -0
- package/test/adapters.test.ts +205 -0
- package/test/anthropic.test.ts +246 -0
- package/test/auto-ui.test.ts +106 -0
- package/test/chatviewport.test.ts +49 -0
- package/test/client.test.ts +327 -0
- package/test/clipboard.test.ts +63 -0
- package/test/command-input.test.ts +183 -0
- package/test/command-tools.test.ts +141 -0
- package/test/commandpolicy.test.ts +252 -0
- package/test/disk-tools.test.ts +220 -0
- package/test/effort.test.ts +123 -0
- package/test/fixtures/openai-tools.sse +84 -0
- package/test/image-adapters.test.ts +78 -0
- package/test/image-ui.test.ts +151 -0
- package/test/images.test.ts +60 -0
- package/test/loop.test.ts +387 -0
- package/test/m5.test.ts +127 -0
- package/test/markdown.test.ts +201 -0
- package/test/repl-ui.test.ts +293 -0
- package/test/sse.test.ts +74 -0
- package/test/steering-ui.test.ts +182 -0
- package/test/steering.test.ts +68 -0
- package/test/terminal-ui.test.ts +144 -0
- package/test/terminal.test.ts +229 -0
- package/test/toolpreview.test.ts +51 -0
- package/test/tools.test.ts +227 -0
- package/test/ui.test.ts +635 -0
- package/test/usage-footer.test.ts +180 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// OpenAI Chat Completions wire <-> normalized types.
|
|
2
|
+
// Arguments travel as JSON strings; tool-call deltas arrive chunked by index and are
|
|
3
|
+
// merged here, never parsed mid-stream.
|
|
4
|
+
|
|
5
|
+
import { RouterError } from '../client.ts';
|
|
6
|
+
import type {
|
|
7
|
+
AssistantTurn,
|
|
8
|
+
ChatMessage,
|
|
9
|
+
ChatRequest,
|
|
10
|
+
StopReason,
|
|
11
|
+
StreamEvent,
|
|
12
|
+
TokenUsage,
|
|
13
|
+
ToolCall,
|
|
14
|
+
} from '../types.ts';
|
|
15
|
+
|
|
16
|
+
type Wire = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
/** Normalized request -> Chat Completions body. */
|
|
19
|
+
export function toWireBody(req: ChatRequest): Wire {
|
|
20
|
+
const messages: Wire[] = [];
|
|
21
|
+
if (req.system) messages.push({ role: 'system', content: req.system });
|
|
22
|
+
for (const m of req.messages) {
|
|
23
|
+
if (m.role === 'assistant') {
|
|
24
|
+
const wire: Wire = { role: 'assistant', content: m.content };
|
|
25
|
+
if (m.toolCalls && m.toolCalls.length > 0) {
|
|
26
|
+
wire.tool_calls = m.toolCalls.map((c) => ({
|
|
27
|
+
id: c.id,
|
|
28
|
+
type: 'function',
|
|
29
|
+
function: { name: c.name, arguments: c.argumentsJson },
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
// A tool_calls-only turn must not carry an empty content string.
|
|
33
|
+
if (!m.content && wire.tool_calls) delete wire.content;
|
|
34
|
+
messages.push(wire);
|
|
35
|
+
} else if (m.role === 'tool') {
|
|
36
|
+
messages.push({ role: 'tool', tool_call_id: m.toolCallId ?? '', content: m.content });
|
|
37
|
+
} else if (m.role === 'user' && m.images?.length) {
|
|
38
|
+
const content: Wire[] = m.content ? [{ type: 'text', text: m.content }] : [];
|
|
39
|
+
content.push(...m.images.map((image) => ({
|
|
40
|
+
type: 'image_url', image_url: { url: `data:${image.mediaType};base64,${image.data}` },
|
|
41
|
+
})));
|
|
42
|
+
messages.push({ role: 'user', content });
|
|
43
|
+
} else {
|
|
44
|
+
messages.push({ role: m.role, content: m.content });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const body: Wire = { model: req.model, messages, stream: req.stream };
|
|
48
|
+
if (req.tools && req.tools.length > 0) {
|
|
49
|
+
body.tools = req.tools.map((t) => ({
|
|
50
|
+
type: 'function',
|
|
51
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
if (req.maxTokens) body.max_tokens = req.maxTokens;
|
|
55
|
+
if (req.effort && req.effort !== 'auto') body.reasoning_effort = req.effort;
|
|
56
|
+
return body;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Lenient tool-arguments parse: {} plus a flag on malformed JSON, never a throw. */
|
|
60
|
+
export function parseToolArgs(raw: string): { arguments: Record<string, unknown>; invalid: boolean } {
|
|
61
|
+
try {
|
|
62
|
+
const value: unknown = JSON.parse(raw || '{}');
|
|
63
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
64
|
+
return { arguments: value as Record<string, unknown>, invalid: false };
|
|
65
|
+
}
|
|
66
|
+
return { arguments: {}, invalid: true };
|
|
67
|
+
} catch {
|
|
68
|
+
return { arguments: {}, invalid: true };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseWireToolCalls(wire: unknown): ToolCall[] {
|
|
73
|
+
if (!Array.isArray(wire)) return [];
|
|
74
|
+
const result: ToolCall[] = [];
|
|
75
|
+
for (const entry of wire) {
|
|
76
|
+
if (typeof entry !== 'object' || entry === null) continue;
|
|
77
|
+
const c = entry as Wire;
|
|
78
|
+
const fn = c.function;
|
|
79
|
+
const name = typeof fn === 'object' && fn !== null ? (fn as Wire).name : undefined;
|
|
80
|
+
if (typeof c.id !== 'string' || typeof name !== 'string') continue;
|
|
81
|
+
const rawArgs = typeof fn === 'object' && fn !== null ? ((fn as Wire).arguments as string | undefined) ?? '' : '';
|
|
82
|
+
const { arguments: parsed, invalid } = parseToolArgs(rawArgs);
|
|
83
|
+
result.push({ id: c.id, name, arguments: parsed, argumentsJson: rawArgs, argumentsInvalid: invalid });
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mapFinishReason(finishReason: unknown): StopReason {
|
|
89
|
+
switch (finishReason) {
|
|
90
|
+
case 'stop':
|
|
91
|
+
return 'stop';
|
|
92
|
+
case 'tool_calls':
|
|
93
|
+
return 'tool_calls';
|
|
94
|
+
case 'length':
|
|
95
|
+
return 'length';
|
|
96
|
+
case 'content_filter':
|
|
97
|
+
return 'refusal';
|
|
98
|
+
default:
|
|
99
|
+
return 'unknown';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseUsage(wire: unknown): TokenUsage | undefined {
|
|
104
|
+
if (typeof wire !== 'object' || wire === null) return undefined;
|
|
105
|
+
const u = wire as Wire;
|
|
106
|
+
if (typeof u.prompt_tokens !== 'number' || typeof u.completion_tokens !== 'number') return undefined;
|
|
107
|
+
return { inputTokens: u.prompt_tokens, outputTokens: u.completion_tokens };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Non-streaming Chat Completions response -> AssistantTurn. */
|
|
111
|
+
export function parseCompleteBody(json: unknown): AssistantTurn {
|
|
112
|
+
const root = json as Wire;
|
|
113
|
+
const choice = (root.choices as Wire[] | undefined)?.[0];
|
|
114
|
+
if (!choice || typeof choice !== 'object') {
|
|
115
|
+
throw new RouterError('The router returned an invalid completion response.');
|
|
116
|
+
}
|
|
117
|
+
const msg = (choice.message ?? {}) as Wire;
|
|
118
|
+
const content = typeof msg.content === 'string' ? msg.content : '';
|
|
119
|
+
const toolCalls = parseWireToolCalls(msg.tool_calls);
|
|
120
|
+
const message: ChatMessage = { role: 'assistant', content };
|
|
121
|
+
if (toolCalls.length > 0) message.toolCalls = toolCalls;
|
|
122
|
+
return {
|
|
123
|
+
message,
|
|
124
|
+
stopReason: mapFinishReason(choice.finish_reason),
|
|
125
|
+
usage: parseUsage(root.usage),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface PendingToolCall {
|
|
130
|
+
index: number;
|
|
131
|
+
id: string;
|
|
132
|
+
name: string;
|
|
133
|
+
args: string;
|
|
134
|
+
started: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Streaming state machine. Feed one SSE `data:` payload per push(); get StreamEvents.
|
|
139
|
+
* Emits a single 'stop' event once finish_reason appears (verified live: the final
|
|
140
|
+
* chunk carries finish_reason + usage).
|
|
141
|
+
*/
|
|
142
|
+
export class OpenAiStreamParser {
|
|
143
|
+
private text = '';
|
|
144
|
+
private reasoning = '';
|
|
145
|
+
private toolCalls: PendingToolCall[] = [];
|
|
146
|
+
private stopped = false;
|
|
147
|
+
|
|
148
|
+
push(payload: string): StreamEvent[] {
|
|
149
|
+
if (this.stopped) return [];
|
|
150
|
+
let json: unknown;
|
|
151
|
+
try {
|
|
152
|
+
json = JSON.parse(payload);
|
|
153
|
+
} catch {
|
|
154
|
+
throw new RouterError('The router returned an invalid streaming response.');
|
|
155
|
+
}
|
|
156
|
+
const root = json as Wire;
|
|
157
|
+
if (root.error) {
|
|
158
|
+
const err = root.error as Wire;
|
|
159
|
+
const message = typeof err.message === 'string' ? err.message : 'The router could not complete the stream.';
|
|
160
|
+
throw new RouterError(message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const events: StreamEvent[] = [];
|
|
164
|
+
const choice = (root.choices as Wire[] | undefined)?.[0];
|
|
165
|
+
const delta = (choice?.delta ?? {}) as Wire;
|
|
166
|
+
|
|
167
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
168
|
+
this.text += delta.content;
|
|
169
|
+
events.push({ kind: 'text', text: delta.content });
|
|
170
|
+
}
|
|
171
|
+
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
|
|
172
|
+
this.reasoning += delta.reasoning_content;
|
|
173
|
+
events.push({ kind: 'reasoning', text: delta.reasoning_content });
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
176
|
+
for (const call of delta.tool_calls as Wire[]) {
|
|
177
|
+
if (typeof call !== 'object' || call === null) continue;
|
|
178
|
+
const index = typeof call.index === 'number' ? call.index : 0;
|
|
179
|
+
let entry = this.toolCalls.find((t) => t.index === index);
|
|
180
|
+
if (!entry) {
|
|
181
|
+
entry = { index, id: '', name: '', args: '', started: false };
|
|
182
|
+
this.toolCalls.push(entry);
|
|
183
|
+
}
|
|
184
|
+
if (typeof call.id === 'string' && call.id) entry.id = call.id;
|
|
185
|
+
const fn = call.function;
|
|
186
|
+
let argDelta = '';
|
|
187
|
+
if (typeof fn === 'object' && fn !== null) {
|
|
188
|
+
if (typeof (fn as Wire).name === 'string' && (fn as Wire).name) entry.name = (fn as Wire).name as string;
|
|
189
|
+
if (typeof (fn as Wire).arguments === 'string') {
|
|
190
|
+
argDelta = (fn as Wire).arguments as string;
|
|
191
|
+
entry.args += argDelta;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!entry.started) {
|
|
195
|
+
entry.started = true;
|
|
196
|
+
events.push({ kind: 'tool_call_start', index, id: entry.id, name: entry.name });
|
|
197
|
+
}
|
|
198
|
+
if (argDelta) {
|
|
199
|
+
events.push({ kind: 'tool_call_args', index, delta: argDelta });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (typeof choice?.finish_reason === 'string' && choice.finish_reason) {
|
|
205
|
+
const usage = parseUsage(root.usage);
|
|
206
|
+
this.stopped = true;
|
|
207
|
+
events.push({ kind: 'stop', stopReason: mapFinishReason(choice.finish_reason), usage });
|
|
208
|
+
}
|
|
209
|
+
return events;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The assembled assistant message for this turn. */
|
|
213
|
+
assistantMessage(): ChatMessage {
|
|
214
|
+
const message: ChatMessage = { role: 'assistant', content: this.text };
|
|
215
|
+
if (this.toolCalls.length > 0) {
|
|
216
|
+
message.toolCalls = this.toolCalls.map((t) => {
|
|
217
|
+
const { arguments: parsed, invalid } = parseToolArgs(t.args);
|
|
218
|
+
return { id: t.id, name: t.name, arguments: parsed, argumentsJson: t.args, argumentsInvalid: invalid };
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return message;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
reasoningSoFar(): string {
|
|
225
|
+
return this.reasoning;
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/approval.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Permission modes are session-local; the legacy auto_approve setting is file-only.
|
|
2
|
+
export const APPROVAL_MODES = ['ask', 'files', 'auto', 'absolute'] as const;
|
|
3
|
+
export type ApprovalMode = typeof APPROVAL_MODES[number];
|
|
4
|
+
|
|
5
|
+
export const APPROVAL_DESCRIPTIONS: Record<ApprovalMode, string> = {
|
|
6
|
+
ask: 'Ask before file changes and terminal commands',
|
|
7
|
+
files: 'Allow project file changes; ask before terminal commands',
|
|
8
|
+
auto: 'Allow files and recognized tasks; ask before risky or unknown commands',
|
|
9
|
+
absolute: 'DANGER: entire drive and arbitrary commands without approval; warning required',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const APPROVAL_LABELS: Record<ApprovalMode, string> = {
|
|
13
|
+
ask: 'ask before writes',
|
|
14
|
+
files: 'files allowed · commands ask',
|
|
15
|
+
auto: 'guarded auto',
|
|
16
|
+
absolute: 'ABSOLUTE — NO APPROVALS',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function parseApprovalMode(value: string): ApprovalMode | undefined {
|
|
20
|
+
return APPROVAL_MODES.find((mode) => mode === value.toLowerCase());
|
|
21
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// A bounded display buffer for app-managed scrollback; the composer is not part of it.
|
|
2
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
3
|
+
|
|
4
|
+
function cells(c: string): number {
|
|
5
|
+
if (/\p{Mark}/u.test(c)) return 0;
|
|
6
|
+
const cp = c.codePointAt(0)!;
|
|
7
|
+
return cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf ||
|
|
8
|
+
cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff ||
|
|
9
|
+
cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff00 && cp <= 0xff60 || cp >= 0x1f300) ? 2 : 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ChatPosition { line: number; length: number }
|
|
13
|
+
|
|
14
|
+
export class ChatViewport {
|
|
15
|
+
private lines = [''];
|
|
16
|
+
private anchors = new Set<ChatPosition>();
|
|
17
|
+
offset = 0;
|
|
18
|
+
|
|
19
|
+
clear(): void { this.lines = ['']; this.anchors.clear(); this.offset = 0; }
|
|
20
|
+
|
|
21
|
+
position(): ChatPosition {
|
|
22
|
+
const position = { line: this.lines.length - 1, length: this.lines.at(-1)!.length };
|
|
23
|
+
this.anchors.add(position);
|
|
24
|
+
return position;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
release(...positions: ChatPosition[]): void {
|
|
28
|
+
for (const position of positions) this.anchors.delete(position);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private trim(): void {
|
|
32
|
+
if (this.lines.length <= 10_000) return;
|
|
33
|
+
const removed = this.lines.length - 10_000;
|
|
34
|
+
this.lines.splice(0, removed);
|
|
35
|
+
for (const anchor of this.anchors) anchor.line = Math.max(0, anchor.line - removed);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
replace(start: ChatPosition, end: ChatPosition, text: string, columns: number): void {
|
|
39
|
+
const before = this.offset ? this.rows(columns).length : 0;
|
|
40
|
+
const oldEndLine = end.line;
|
|
41
|
+
const oldEndLength = end.length;
|
|
42
|
+
const prefix = this.lines[start.line]?.slice(0, start.length) ?? '';
|
|
43
|
+
const suffix = this.lines[end.line]?.slice(end.length) ?? '';
|
|
44
|
+
const inserted = (prefix + text).split('\n');
|
|
45
|
+
const newEndLine = start.line + inserted.length - 1;
|
|
46
|
+
const newEndLength = inserted.at(-1)!.length;
|
|
47
|
+
inserted[inserted.length - 1] += suffix;
|
|
48
|
+
this.lines.splice(start.line, oldEndLine - start.line + 1, ...inserted);
|
|
49
|
+
const delta = newEndLine - oldEndLine;
|
|
50
|
+
for (const anchor of this.anchors) {
|
|
51
|
+
if (anchor === start) continue;
|
|
52
|
+
if (anchor === end) { anchor.line = newEndLine; anchor.length = newEndLength; }
|
|
53
|
+
else if (anchor.line > oldEndLine) anchor.line += delta;
|
|
54
|
+
else if (anchor.line === oldEndLine && anchor.length >= oldEndLength) {
|
|
55
|
+
anchor.line = newEndLine;
|
|
56
|
+
anchor.length = newEndLength + anchor.length - oldEndLength;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
this.trim();
|
|
60
|
+
if (this.offset) this.offset = Math.max(0, this.offset + this.rows(columns).length - before);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
remove(start: ChatPosition, end: ChatPosition): void {
|
|
64
|
+
this.replace(start, end, '', 80);
|
|
65
|
+
this.offset = 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
append(text: string, columns: number): void {
|
|
69
|
+
const before = this.offset ? this.rows(columns).length : 0;
|
|
70
|
+
for (const part of text.split(/(\n|\r\x1b\[K)/)) {
|
|
71
|
+
if (part === '\n') this.lines.push('');
|
|
72
|
+
else if (part === '\r\x1b[K') this.lines[this.lines.length - 1] = '';
|
|
73
|
+
else this.lines[this.lines.length - 1] += part;
|
|
74
|
+
}
|
|
75
|
+
this.trim();
|
|
76
|
+
if (this.offset) this.offset = Math.max(0, this.offset + this.rows(columns).length - before);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
rows(columns: number): string[] {
|
|
80
|
+
const rows: string[] = [];
|
|
81
|
+
columns = Math.max(1, columns);
|
|
82
|
+
let style = '';
|
|
83
|
+
for (const line of this.lines) {
|
|
84
|
+
let row = style, used = 0;
|
|
85
|
+
for (const token of line.match(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b.|[^\x1b]/gu) ?? []) {
|
|
86
|
+
if (token.startsWith('\x1b')) {
|
|
87
|
+
if (/^\x1b\[[\d;]*m$/.test(token)) {
|
|
88
|
+
row += token;
|
|
89
|
+
style = /^\x1b\[(0)?m$/.test(token) ? '' : style + token;
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (token === '\r' || token < ' ') continue;
|
|
94
|
+
if (used + cells(token) > columns) {
|
|
95
|
+
rows.push(row + (style ? '\x1b[0m' : ''));
|
|
96
|
+
row = style;
|
|
97
|
+
used = 0;
|
|
98
|
+
}
|
|
99
|
+
row += token;
|
|
100
|
+
used += cells(token);
|
|
101
|
+
}
|
|
102
|
+
rows.push(row + (style ? '\x1b[0m' : ''));
|
|
103
|
+
}
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
scroll(delta: number, columns: number, height: number): void {
|
|
108
|
+
this.offset = Math.max(0, Math.min(Math.max(0, this.rows(columns).length - height), this.offset + delta));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
visible(columns: number, height: number): string[] {
|
|
112
|
+
const rows = this.rows(columns);
|
|
113
|
+
const end = Math.max(height, rows.length - this.offset);
|
|
114
|
+
this.offset = Math.min(this.offset, Math.max(0, rows.length - height));
|
|
115
|
+
return rows.slice(Math.max(0, end - height), end);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
caret(columns: number, height: number): { row: number; column: number } {
|
|
119
|
+
const rows = this.rows(columns);
|
|
120
|
+
const last = stripVTControlCharacters(rows.at(-1) ?? '');
|
|
121
|
+
return { row: Math.min(height, rows.length), column: Math.min(columns + 1, [...last].reduce((sum, c) => sum + cells(c), 0) + 1) };
|
|
122
|
+
}
|
|
123
|
+
}
|