moqi-tui 0.2.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 +21 -0
- package/README.md +782 -0
- package/bin/moqi.mjs +40 -0
- package/cordis.patch.yml +41 -0
- package/lib/cross-find.js +217 -0
- package/lib/file-index.js +121 -0
- package/lib/fleet-sources.js +114 -0
- package/lib/index.js +3999 -0
- package/lib/persist.js +194 -0
- package/lib/plugins.js +371 -0
- package/lib/presence.js +144 -0
- package/lib/rename.js +35 -0
- package/lib/rewind.js +94 -0
- package/lib/sessions-store.js +134 -0
- package/lib/startup.js +92 -0
- package/lib/tui/atfile.js +154 -0
- package/lib/tui/export.js +48 -0
- package/lib/tui/fleet.js +346 -0
- package/lib/tui/i18n.js +201 -0
- package/lib/tui/jobs.js +65 -0
- package/lib/tui/keys.js +205 -0
- package/lib/tui/markdown.js +368 -0
- package/lib/tui/mcp.js +95 -0
- package/lib/tui/panels.js +231 -0
- package/lib/tui/screen.js +156 -0
- package/lib/tui/state.js +502 -0
- package/lib/tui/stream.js +109 -0
- package/lib/tui/text.js +173 -0
- package/lib/tui/theme.js +183 -0
- package/lib/tui/themes.js +153 -0
- package/lib/tui/tooldetail.js +140 -0
- package/lib/tui/view.js +830 -0
- package/lib/tui/vim.js +222 -0
- package/lib/tui-host-core.js +141 -0
- package/lib/tui-host.js +48 -0
- package/lib/types/cross-find.d.ts +66 -0
- package/lib/types/file-index.d.ts +34 -0
- package/lib/types/fleet-sources.d.ts +34 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/persist.d.ts +116 -0
- package/lib/types/plugins.d.ts +218 -0
- package/lib/types/presence.d.ts +48 -0
- package/lib/types/rename.d.ts +32 -0
- package/lib/types/rewind.d.ts +75 -0
- package/lib/types/sessions-store.d.ts +46 -0
- package/lib/types/startup.d.ts +45 -0
- package/lib/types/tui/atfile.d.ts +90 -0
- package/lib/types/tui/export.d.ts +18 -0
- package/lib/types/tui/fleet.d.ts +209 -0
- package/lib/types/tui/i18n.d.ts +34 -0
- package/lib/types/tui/jobs.d.ts +28 -0
- package/lib/types/tui/keys.d.ts +52 -0
- package/lib/types/tui/markdown.d.ts +14 -0
- package/lib/types/tui/mcp.d.ts +34 -0
- package/lib/types/tui/panels.d.ts +125 -0
- package/lib/types/tui/screen.d.ts +79 -0
- package/lib/types/tui/state.d.ts +323 -0
- package/lib/types/tui/stream.d.ts +78 -0
- package/lib/types/tui/text.d.ts +28 -0
- package/lib/types/tui/theme.d.ts +87 -0
- package/lib/types/tui/themes.d.ts +70 -0
- package/lib/types/tui/tooldetail.d.ts +45 -0
- package/lib/types/tui/view.d.ts +163 -0
- package/lib/types/tui/vim.d.ts +64 -0
- package/lib/types/tui-host-core.d.ts +62 -0
- package/lib/types/tui-host.d.ts +42 -0
- package/lib/types/version.d.ts +8 -0
- package/lib/types/voice.d.ts +227 -0
- package/lib/version.js +32 -0
- package/lib/voice.js +405 -0
- package/package.json +119 -0
- package/scripts/harness-root.mjs +88 -0
- package/scripts/install-profile.mjs +133 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust-surface panels: tool approval, `ask_user_question`, and plan review.
|
|
3
|
+
*
|
|
4
|
+
* These are the moments the agent stops and asks a human, so they own the
|
|
5
|
+
* keyboard while open and answer through the Harness waterfall seams. Like the
|
|
6
|
+
* rest of `tui/`, this module is pure state plus a view shape — the app layer
|
|
7
|
+
* does the Cordis wiring and the renderer does the drawing.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import { t } from "./i18n.js";
|
|
11
|
+
/**
|
|
12
|
+
* Read a spoken (transcribed) answer as an approval decision.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately narrow: a misheard sentence must never grant a tool call, so
|
|
15
|
+
* only unambiguous words decide and anything else returns `undefined`, leaving
|
|
16
|
+
* the panel waiting. Both shipped interface languages are accepted.
|
|
17
|
+
*/
|
|
18
|
+
export function interpretApproval(text) {
|
|
19
|
+
const words = text
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[.,!?;:,。!?;:]/g, ' ')
|
|
22
|
+
.split(/\s+/)
|
|
23
|
+
.filter((word) => word !== '');
|
|
24
|
+
const yes = new Set(['allow', 'allowed', 'yes', 'yeah', 'ok', 'okay', 'approve', 'approved', 'go', 'run', '允许', '同意', '可以', '好的', '好', '是', '行']);
|
|
25
|
+
const no = new Set(['deny', 'denied', 'no', 'nope', 'reject', 'rejected', 'stop', 'cancel', '拒绝', '不行', '不要', '不', '取消', '否']);
|
|
26
|
+
// A denial wins when both appear ("no, don't allow it"): the safe reading of
|
|
27
|
+
// an ambiguous sentence is the one that does not run a tool.
|
|
28
|
+
if (words.some((word) => no.has(word)))
|
|
29
|
+
return 'rejected';
|
|
30
|
+
if (words.some((word) => yes.has(word)))
|
|
31
|
+
return 'allowed-once';
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* One pending tool approval.
|
|
36
|
+
*
|
|
37
|
+
* There is no persistent grant in the protocol — "allow once" or deny — so the
|
|
38
|
+
* panel offers exactly those two rows and no "always" temptation.
|
|
39
|
+
*/
|
|
40
|
+
export class ApprovalPanel {
|
|
41
|
+
selected = 0;
|
|
42
|
+
toolName;
|
|
43
|
+
reason;
|
|
44
|
+
command;
|
|
45
|
+
constructor(toolName, reason, command) {
|
|
46
|
+
this.toolName = toolName;
|
|
47
|
+
this.reason = reason;
|
|
48
|
+
this.command = command;
|
|
49
|
+
}
|
|
50
|
+
move(delta) {
|
|
51
|
+
this.selected = this.selected + delta < 0 ? 0 : Math.min(this.selected + delta, 1);
|
|
52
|
+
}
|
|
53
|
+
decision() {
|
|
54
|
+
return this.selected === 0 ? 'allowed-once' : 'rejected';
|
|
55
|
+
}
|
|
56
|
+
view() {
|
|
57
|
+
const detail = [this.reason ?? '', this.command === undefined ? '' : `\`\`\`sh\n${this.command}\n\`\`\``]
|
|
58
|
+
.filter((part) => part !== '')
|
|
59
|
+
.join('\n\n');
|
|
60
|
+
return {
|
|
61
|
+
kind: 'approval',
|
|
62
|
+
title: t('approval.title', { tool: this.toolName }),
|
|
63
|
+
detail,
|
|
64
|
+
rows: [
|
|
65
|
+
{ label: t('approval.allow'), description: t('approval.allowDetail'), selected: this.selected === 0 },
|
|
66
|
+
{ label: t('approval.deny'), description: t('approval.denyDetail'), selected: this.selected === 1 },
|
|
67
|
+
],
|
|
68
|
+
hint: t('approval.hint'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Raised when the user backs out of a question set; the service maps it to `ASK_CANCELLED`. */
|
|
73
|
+
export const ASK_CANCELLED_CODE = 'ASK_CANCELLED';
|
|
74
|
+
/**
|
|
75
|
+
* The `ask_user_question` flow, question by question.
|
|
76
|
+
*
|
|
77
|
+
* Single-select answers on `enter`; multi-select toggles with `space` and
|
|
78
|
+
* collects with `enter`; `tab` moves to the free-text line, and typing on an
|
|
79
|
+
* option row combines that option's label with the text, the way a form does.
|
|
80
|
+
* A plan review is the same shape with a markdown body and an approve label
|
|
81
|
+
* named by the protocol — approval never carries feedback, because the
|
|
82
|
+
* protocol reads feedback as "keep planning".
|
|
83
|
+
*/
|
|
84
|
+
export class QuestionsPanel {
|
|
85
|
+
index = 0;
|
|
86
|
+
focus = 'options';
|
|
87
|
+
drafts = new Map();
|
|
88
|
+
questions;
|
|
89
|
+
constructor(questions) {
|
|
90
|
+
this.questions = questions;
|
|
91
|
+
for (const question of questions) {
|
|
92
|
+
this.drafts.set(question.id, { selected: [], custom: '', cursor: 0 });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
get question() {
|
|
96
|
+
return this.questions[this.index];
|
|
97
|
+
}
|
|
98
|
+
get draft() {
|
|
99
|
+
const question = this.question;
|
|
100
|
+
if (question === undefined)
|
|
101
|
+
return { selected: [], custom: '', cursor: 0 };
|
|
102
|
+
let draft = this.drafts.get(question.id);
|
|
103
|
+
if (draft === undefined) {
|
|
104
|
+
draft = { selected: [], custom: '', cursor: 0 };
|
|
105
|
+
this.drafts.set(question.id, draft);
|
|
106
|
+
}
|
|
107
|
+
return draft;
|
|
108
|
+
}
|
|
109
|
+
/** Whether this set is a plan under review rather than a question. */
|
|
110
|
+
get isPlanReview() {
|
|
111
|
+
return this.question?.intent?.kind === 'plan-review';
|
|
112
|
+
}
|
|
113
|
+
move(delta) {
|
|
114
|
+
const options = this.question?.options ?? [];
|
|
115
|
+
if (options.length === 0)
|
|
116
|
+
return;
|
|
117
|
+
this.focus = 'options';
|
|
118
|
+
const next = this.draft.cursor + delta;
|
|
119
|
+
this.draft.cursor = next < 0 ? 0 : Math.min(next, options.length - 1);
|
|
120
|
+
}
|
|
121
|
+
/** Space toggles the highlighted option on a multi-select question. */
|
|
122
|
+
toggle() {
|
|
123
|
+
const question = this.question;
|
|
124
|
+
const options = question?.options ?? [];
|
|
125
|
+
const option = options[this.draft.cursor];
|
|
126
|
+
if (question === undefined || option === undefined)
|
|
127
|
+
return;
|
|
128
|
+
if (question.multiSelect !== true) {
|
|
129
|
+
this.draft.selected = [option.label];
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const at = this.draft.selected.indexOf(option.label);
|
|
133
|
+
if (at === -1)
|
|
134
|
+
this.draft.selected.push(option.label);
|
|
135
|
+
else
|
|
136
|
+
this.draft.selected.splice(at, 1);
|
|
137
|
+
}
|
|
138
|
+
focusCustom() {
|
|
139
|
+
this.focus = 'custom';
|
|
140
|
+
}
|
|
141
|
+
/** Type into the free-text line; on an option row the option joins the answer. */
|
|
142
|
+
typeText(chunk) {
|
|
143
|
+
const question = this.question;
|
|
144
|
+
const options = question?.options ?? [];
|
|
145
|
+
const option = options[this.draft.cursor];
|
|
146
|
+
if (this.focus === 'options' && option !== undefined && question?.multiSelect !== true) {
|
|
147
|
+
this.draft.selected = [option.label];
|
|
148
|
+
}
|
|
149
|
+
this.focus = 'custom';
|
|
150
|
+
this.draft.custom += chunk;
|
|
151
|
+
}
|
|
152
|
+
backspaceText() {
|
|
153
|
+
this.focus = 'custom';
|
|
154
|
+
this.draft.custom = this.draft.custom.slice(0, -1);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Commit the current question and move on.
|
|
158
|
+
*
|
|
159
|
+
* @returns `'next'` when another question follows, `'done'` when the set is
|
|
160
|
+
* answered, and `'empty'` when nothing has been chosen yet.
|
|
161
|
+
*/
|
|
162
|
+
advance() {
|
|
163
|
+
const question = this.question;
|
|
164
|
+
if (question === undefined)
|
|
165
|
+
return 'done';
|
|
166
|
+
const draft = this.draft;
|
|
167
|
+
const hasAnswer = draft.selected.length > 0 || draft.custom.trim() !== '';
|
|
168
|
+
if (!hasAnswer)
|
|
169
|
+
return 'empty';
|
|
170
|
+
if (this.index + 1 < this.questions.length) {
|
|
171
|
+
this.index += 1;
|
|
172
|
+
this.focus = 'options';
|
|
173
|
+
return 'next';
|
|
174
|
+
}
|
|
175
|
+
return 'done';
|
|
176
|
+
}
|
|
177
|
+
/** Step back one question, keeping what was already chosen. */
|
|
178
|
+
back() {
|
|
179
|
+
if (this.index === 0)
|
|
180
|
+
return false;
|
|
181
|
+
this.index -= 1;
|
|
182
|
+
this.focus = 'options';
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
/** The finished answer set, in question order. */
|
|
186
|
+
answers() {
|
|
187
|
+
return this.questions.map((question) => {
|
|
188
|
+
const draft = this.drafts.get(question.id) ?? { selected: [], custom: '', cursor: 0 };
|
|
189
|
+
const custom = draft.custom.trim();
|
|
190
|
+
const approve = question.intent?.kind === 'plan-review' && draft.selected[0] === question.intent.approve;
|
|
191
|
+
return {
|
|
192
|
+
id: question.id,
|
|
193
|
+
selected: draft.selected,
|
|
194
|
+
// Approval must not carry feedback: the protocol reads it as a
|
|
195
|
+
// request to keep planning instead of a decision.
|
|
196
|
+
custom: custom === '' || approve ? undefined : custom,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
view() {
|
|
201
|
+
const question = this.question;
|
|
202
|
+
const options = question?.options ?? [];
|
|
203
|
+
const draft = this.draft;
|
|
204
|
+
const rows = options.map((option, index) => ({
|
|
205
|
+
label: option.label,
|
|
206
|
+
description: option.description,
|
|
207
|
+
selected: this.focus === 'options' && index === draft.cursor,
|
|
208
|
+
checked: question?.multiSelect === true ? draft.selected.includes(option.label) : undefined,
|
|
209
|
+
}));
|
|
210
|
+
const total = this.questions.length;
|
|
211
|
+
const title = question?.header ??
|
|
212
|
+
(total > 1
|
|
213
|
+
? t('questions.of', { n: this.index + 1, total })
|
|
214
|
+
: t('questions.title'));
|
|
215
|
+
const hint = this.isPlanReview
|
|
216
|
+
? t('questions.hintPlan')
|
|
217
|
+
: question?.multiSelect === true
|
|
218
|
+
? t('questions.hintMulti')
|
|
219
|
+
: t('questions.hint');
|
|
220
|
+
return {
|
|
221
|
+
kind: 'questions',
|
|
222
|
+
title,
|
|
223
|
+
detail: question?.detail === undefined ? (question?.question ?? '') : `${question.question}\n\n${question.detail}`,
|
|
224
|
+
rows,
|
|
225
|
+
hint: this.index === 0 ? `${hint}${t('questions.cancelSuffix')}` : hint,
|
|
226
|
+
inputLabel: t('questions.answer'),
|
|
227
|
+
inputText: draft.custom,
|
|
228
|
+
inputFocused: this.focus === 'custom',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal driver: raw mode, the alternate screen, and frame painting.
|
|
3
|
+
*
|
|
4
|
+
* Frames are painted line by line against the previous frame so a streaming
|
|
5
|
+
* reply only rewrites the lines that actually changed. That keeps a fast token
|
|
6
|
+
* stream from flickering the whole screen, which a naive full clear-and-redraw
|
|
7
|
+
* does at any real terminal size.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import { createDecoder } from "./keys.js";
|
|
11
|
+
import { displayWidth, truncate } from "./text.js";
|
|
12
|
+
const ESC = '';
|
|
13
|
+
const ALT_SCREEN_ON = `${ESC}[?1049h`;
|
|
14
|
+
const ALT_SCREEN_OFF = `${ESC}[?1049l`;
|
|
15
|
+
/**
|
|
16
|
+
* Button reporting plus SGR encoding, enabled only so the wheel can scroll the
|
|
17
|
+
* transcript: the alternate screen has no terminal scrollback of its own, so
|
|
18
|
+
* without this the wheel does nothing at all. Most terminals still allow text
|
|
19
|
+
* selection while reporting is on by holding shift.
|
|
20
|
+
*/
|
|
21
|
+
const MOUSE_ON = `${ESC}[?1000h${ESC}[?1006h`;
|
|
22
|
+
const MOUSE_OFF = `${ESC}[?1006l${ESC}[?1000l`;
|
|
23
|
+
const CURSOR_HIDE = `${ESC}[?25l`;
|
|
24
|
+
const CURSOR_SHOW = `${ESC}[?25h`;
|
|
25
|
+
const CLEAR_ALL = `${ESC}[2J`;
|
|
26
|
+
const RESET_SGR = `${ESC}[0m`;
|
|
27
|
+
/** Move the cursor to a 1-indexed row and column. */
|
|
28
|
+
function moveTo(row, column) {
|
|
29
|
+
return `${ESC}[${row};${column}H`;
|
|
30
|
+
}
|
|
31
|
+
/** Erase from the cursor to the end of the current line. */
|
|
32
|
+
const CLEAR_LINE = `${ESC}[K`;
|
|
33
|
+
/** Fallback geometry when the terminal reports nothing usable. */
|
|
34
|
+
const DEFAULT_COLUMNS = 80;
|
|
35
|
+
const DEFAULT_ROWS = 24;
|
|
36
|
+
/**
|
|
37
|
+
* Clamp a reported terminal size to something drawable.
|
|
38
|
+
*
|
|
39
|
+
* A stream can report `0` as well as `undefined` — a pty opened without a
|
|
40
|
+
* window size does exactly that — and `?? 80` does not catch a zero. Left
|
|
41
|
+
* alone, the whole frame collapses to zero-width lines and the app paints
|
|
42
|
+
* nothing but cursor moves, which looks like a hang rather than a sizing
|
|
43
|
+
* problem.
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeSize(columns, rows) {
|
|
46
|
+
return {
|
|
47
|
+
columns: columns !== undefined && columns > 0 ? columns : DEFAULT_COLUMNS,
|
|
48
|
+
rows: rows !== undefined && rows > 0 ? rows : DEFAULT_ROWS,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Owns stdin/stdout for the lifetime of the app. Construction does not touch
|
|
53
|
+
* the terminal; {@link Screen.start} does, and {@link Screen.stop} is safe to
|
|
54
|
+
* call more than once so teardown paths can be blunt.
|
|
55
|
+
*/
|
|
56
|
+
export class Screen {
|
|
57
|
+
previous = [];
|
|
58
|
+
started = false;
|
|
59
|
+
decode;
|
|
60
|
+
onData;
|
|
61
|
+
onResize;
|
|
62
|
+
cursor;
|
|
63
|
+
handlers;
|
|
64
|
+
mouse;
|
|
65
|
+
constructor(handlers, options = {}) {
|
|
66
|
+
this.handlers = handlers;
|
|
67
|
+
this.mouse = options.mouse === true;
|
|
68
|
+
// The decoder emits a lone escape asynchronously — nothing else will carry
|
|
69
|
+
// it — so it needs the same handler the chunk path uses.
|
|
70
|
+
this.decode = createDecoder((key) => {
|
|
71
|
+
this.handlers.onKey(key);
|
|
72
|
+
});
|
|
73
|
+
this.onData = (chunk) => {
|
|
74
|
+
for (const key of this.decode(chunk.toString('utf8'))) {
|
|
75
|
+
this.handlers.onKey(key);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
this.onResize = () => {
|
|
79
|
+
// A resize invalidates every cached line: the wrap points all moved.
|
|
80
|
+
this.previous = [];
|
|
81
|
+
this.handlers.onResize(this.size());
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Current terminal size, with defaults for a non-TTY stdout. */
|
|
85
|
+
size() {
|
|
86
|
+
return normalizeSize(process.stdout.columns, process.stdout.rows);
|
|
87
|
+
}
|
|
88
|
+
/** Whether this process is attached to a real terminal on both ends. */
|
|
89
|
+
static isInteractive() {
|
|
90
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
91
|
+
}
|
|
92
|
+
/** Enter the alternate screen and begin delivering keys. */
|
|
93
|
+
start() {
|
|
94
|
+
if (this.started)
|
|
95
|
+
return;
|
|
96
|
+
this.started = true;
|
|
97
|
+
if (process.stdin.isTTY === true)
|
|
98
|
+
process.stdin.setRawMode(true);
|
|
99
|
+
process.stdin.resume();
|
|
100
|
+
process.stdin.on('data', this.onData);
|
|
101
|
+
process.stdout.on('resize', this.onResize);
|
|
102
|
+
process.stdout.write(ALT_SCREEN_ON + (this.mouse ? MOUSE_ON : '') + CURSOR_HIDE + CLEAR_ALL);
|
|
103
|
+
}
|
|
104
|
+
/** Restore the terminal. Safe to call repeatedly and after a failed start. */
|
|
105
|
+
stop() {
|
|
106
|
+
if (!this.started)
|
|
107
|
+
return;
|
|
108
|
+
this.started = false;
|
|
109
|
+
// Drop a held escape too: after teardown no key may reach the app.
|
|
110
|
+
this.decode.dispose();
|
|
111
|
+
process.stdin.off('data', this.onData);
|
|
112
|
+
process.stdout.off('resize', this.onResize);
|
|
113
|
+
if (process.stdin.isTTY === true)
|
|
114
|
+
process.stdin.setRawMode(false);
|
|
115
|
+
process.stdin.pause();
|
|
116
|
+
process.stdout.write(RESET_SGR + (this.mouse ? MOUSE_OFF : '') + CURSOR_SHOW + ALT_SCREEN_OFF);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Place the hardware cursor on the next paint, in 0-indexed screen
|
|
120
|
+
* coordinates. Passing `undefined` hides it.
|
|
121
|
+
*/
|
|
122
|
+
setCursor(position) {
|
|
123
|
+
this.cursor = position;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Paint a frame. `frame` is the whole screen as lines; missing lines are
|
|
127
|
+
* treated as blank so the caller need not pad to the window height.
|
|
128
|
+
*/
|
|
129
|
+
paint(frame) {
|
|
130
|
+
if (!this.started)
|
|
131
|
+
return;
|
|
132
|
+
const { columns, rows } = this.size();
|
|
133
|
+
let out = '';
|
|
134
|
+
for (let row = 0; row < rows; row += 1) {
|
|
135
|
+
const line = frame[row] ?? '';
|
|
136
|
+
const clipped = displayWidth(line) > columns ? truncate(line, columns) : line;
|
|
137
|
+
if (this.previous[row] === clipped)
|
|
138
|
+
continue;
|
|
139
|
+
out += moveTo(row + 1, 1) + CLEAR_LINE + clipped + RESET_SGR;
|
|
140
|
+
this.previous[row] = clipped;
|
|
141
|
+
}
|
|
142
|
+
this.previous.length = rows;
|
|
143
|
+
if (this.cursor === undefined) {
|
|
144
|
+
out += CURSOR_HIDE;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
out += moveTo(this.cursor.row + 1, this.cursor.column + 1) + CURSOR_SHOW;
|
|
148
|
+
}
|
|
149
|
+
if (out !== '')
|
|
150
|
+
process.stdout.write(out);
|
|
151
|
+
}
|
|
152
|
+
/** Drop the cached frame so the next paint rewrites every line. */
|
|
153
|
+
invalidate() {
|
|
154
|
+
this.previous = [];
|
|
155
|
+
}
|
|
156
|
+
}
|