@textui/terminal 0.2.0 → 0.4.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/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export * as ansi from './ansi.js';
2
2
  export { Writer, createWriter } from './writer.js';
3
3
  export { captureBuffer } from './capture.js';
4
4
  export type { CaptureOptions } from './capture.js';
5
+ export { renderStill } from './still.js';
6
+ export type { Still, StillOptions } from './still.js';
5
7
  export { bufferToSvg } from './svg.js';
6
8
  export type { SvgOptions } from './svg.js';
7
9
  export { detectCapabilities, detectColorDepth, detectUnicode, applyOverrides, describeEnvironment, } from './capabilities.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EACL,kBAAkB,EAAE,gBAAgB,EAAE,aAAa,EACnD,cAAc,EAAE,mBAAmB,GACpC,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACzD,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AACpE,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACnG,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC7E,YAAY,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,YAAY,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EACL,kBAAkB,EAAE,gBAAgB,EAAE,aAAa,EACnD,cAAc,EAAE,mBAAmB,GACpC,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACzD,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AACpE,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACnG,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC7E,YAAY,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * as ansi from './ansi.js';
2
2
  export { Writer, createWriter } from './writer.js';
3
3
  export { captureBuffer } from './capture.js';
4
+ export { renderStill } from './still.js';
4
5
  export { bufferToSvg } from './svg.js';
5
6
  export { detectCapabilities, detectColorDepth, detectUnicode, applyOverrides, describeEnvironment, } from './capabilities.js';
6
7
  export { InputDecoder, createDecoder } from './input.js';
@@ -0,0 +1,71 @@
1
+ import type { CapabilityOverrides, CellBuffer, CreateAppOptions, TextUIApp } from '@textui/core';
2
+ import type { CaptureOptions } from './capture.js';
3
+ /**
4
+ * One frame of an application, as text.
5
+ *
6
+ * The same application, not a second rendering path: it is mounted against a
7
+ * terminal that is a size and nothing else, rendered until it stops changing,
8
+ * and every cell it painted is what comes back. Which is what a program does
9
+ * when its output is a pipe rather than a screen - there is no frame after
10
+ * this one to correct it, so it has to be the finished picture.
11
+ *
12
+ * Every example in this repository had written this out by hand, and all of
13
+ * them ended the same way: a sleep loop of four milliseconds times a number
14
+ * somebody had tried until the picture looked right. Eight, mostly - four in
15
+ * one, twelve in another - for the same eight lines of setup either side. The
16
+ * number was the tell. Nobody knew it, a small one silently writes a
17
+ * half-drawn frame, and `TextUIApp.settled` is the answer they were all
18
+ * approximating.
19
+ */
20
+ export interface StillOptions extends Omit<CreateAppOptions, 'terminal' | 'session'> {
21
+ /** Columns. The terminal is only a size, so this is the whole of it. */
22
+ width?: number;
23
+ height?: number;
24
+ /** What the terminal should claim to be able to do. */
25
+ capabilities?: CapabilityOverrides;
26
+ /**
27
+ * Drive it before the frame is taken.
28
+ *
29
+ * A still of an application in its opening state is the least interesting
30
+ * one. This is where a screen is pushed, a message sent, or a scripted host
31
+ * pumped to the point worth photographing - and it may be async, because
32
+ * most of those are.
33
+ */
34
+ before?(app: TextUIApp): void | Promise<void>;
35
+ /**
36
+ * The frame is drawn and the application is still alive.
37
+ *
38
+ * For what a string cannot carry and `stop` would take away: the buffer, to
39
+ * crop it to the rows that were used, and the theme, whose two colours are
40
+ * the honest background for a picture of *this* screen rather than the
41
+ * exporter's guess. It runs before the capture, so a buffer changed here is
42
+ * the buffer that comes back as `text`.
43
+ */
44
+ after?(app: TextUIApp): void | Promise<void>;
45
+ /** Plain text or SGR, and at what depth. Defaults to what the terminal claims. */
46
+ capture?: CaptureOptions;
47
+ /** Settle passes before giving up. See `TextUIApp.settled`. */
48
+ settleLimit?: number;
49
+ }
50
+ export interface Still {
51
+ /** Every cell it painted, rows separated by newlines. */
52
+ text: string;
53
+ /**
54
+ * The cells themselves, for anything text cannot carry - `bufferToSvg`, a
55
+ * pixel diff, an assertion about one cell's colour. Valid after the
56
+ * application has stopped: nothing writes to it again.
57
+ */
58
+ buffer: CellBuffer;
59
+ /**
60
+ * Whether it went quiet, or the limit ran out first.
61
+ *
62
+ * An application that animates settles between its frames, so this is
63
+ * `true` for one of those and the picture is a photograph of something
64
+ * moving. `false` means the passes never stopped producing work - a render
65
+ * loop that does not converge - and the frame was taken anyway, because a
66
+ * still is better evidence of that than nothing is.
67
+ */
68
+ settled: boolean;
69
+ }
70
+ export declare function renderStill(options?: StillOptions): Promise<Still>;
71
+ //# sourceMappingURL=still.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"still.d.ts","sourceRoot":"","sources":["../src/still.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,mBAAmB,EAAE,UAAU,EAAE,gBAAgB,EAAE,SAAS,EAC7D,MAAM,cAAc,CAAC;AAKtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,YAAa,SAAQ,IAAI,CAAC,gBAAgB,EAAE,UAAU,GAAG,SAAS,CAAC;IAClF,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC;;;;;;;OAOG;IACH,MAAM,CAAC,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,kFAAkF;IAClF,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,KAAK;IACpB,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,MAAM,EAAE,UAAU,CAAC;IACnB;;;;;;;;OAQG;IACH,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAsB,WAAW,CAAC,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,KAAK,CAAC,CAgC5E"}
package/dist/still.js ADDED
@@ -0,0 +1,29 @@
1
+ import { WRITER_KEY, createApp } from '@textui/core';
2
+ import { createVirtualTerminal } from './virtual.js';
3
+ import { createWriter } from './writer.js';
4
+ import { captureBuffer } from './capture.js';
5
+ export async function renderStill(options = {}) {
6
+ const { width = 80, height = 24, capabilities, before, after, capture, settleLimit, ...app } = options;
7
+ const terminal = createVirtualTerminal({
8
+ width,
9
+ height,
10
+ ...(capabilities ? { capabilities } : {}),
11
+ });
12
+ const created = createApp({ ...app, terminal });
13
+ // The writer is provided even though the buffer is what is read: a virtual
14
+ // terminal records what was written to it, and a still that quietly stopped
15
+ // producing that would break anything reading the bytes rather than the
16
+ // cells.
17
+ created.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
18
+ await created.start();
19
+ await before?.(created);
20
+ const settled = await created.settled(settleLimit === undefined ? {} : { limit: settleLimit });
21
+ // Even when it did not settle: the limit means "stop waiting", not "give up
22
+ // on the frame", and a still of a moving thing is still a still.
23
+ created.flush();
24
+ await after?.(created);
25
+ const text = captureBuffer(created.buffer(), terminal.capabilities(), capture ?? {});
26
+ const buffer = created.buffer();
27
+ await created.stop();
28
+ return { text, buffer, settled };
29
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"writer.d.ts","sourceRoot":"","sources":["../src/writer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAO,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AA8BzD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,oBAAoB,CAAC,YAAY,CAAC,GAAG,MAAM,CAU3F;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,oBAAoB,CAAC,YAAY,CAAC,GAAG,MAAM,CAU3F;AAED,eAAO,MAAM,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EASrC,CAAC;AAMF,qBAAa,MAAM;IAGL,OAAO,CAAC,YAAY;IAFhC,OAAO,CAAC,KAAK,CAAgB;gBAET,YAAY,EAAE,oBAAoB;IAEtD,eAAe,CAAC,YAAY,EAAE,oBAAoB,GAAG,IAAI;IAKzD,yEAAyE;IACzE,UAAU,IAAI,IAAI;IAIlB,oEAAoE;IACpE,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM;IA8B3B,OAAO,CAAC,SAAS;IA0BjB,OAAO,CAAC,WAAW;CA6CpB;AAYD,wBAAgB,YAAY,CAAC,YAAY,EAAE,oBAAoB,GAAG,MAAM,CAEvE"}
1
+ {"version":3,"file":"writer.d.ts","sourceRoot":"","sources":["../src/writer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAO,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AA0CzD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,oBAAoB,CAAC,YAAY,CAAC,GAAG,MAAM,CAU3F;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,oBAAoB,CAAC,YAAY,CAAC,GAAG,MAAM,CAU3F;AAED,eAAO,MAAM,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EASrC,CAAC;AAMF,qBAAa,MAAM;IAGL,OAAO,CAAC,YAAY;IAFhC,OAAO,CAAC,KAAK,CAAgB;gBAET,YAAY,EAAE,oBAAoB;IAEtD,eAAe,CAAC,YAAY,EAAE,oBAAoB,GAAG,IAAI;IAKzD,yEAAyE;IACzE,UAAU,IAAI,IAAI;IAIlB,oEAAoE;IACpE,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM;IAuC3B,OAAO,CAAC,SAAS;IA0BjB,OAAO,CAAC,WAAW;CA6CpB;AAYD,wBAAgB,YAAY,CAAC,YAAY,EAAE,oBAAoB,GAAG,MAAM,CAEvE"}
package/dist/writer.js CHANGED
@@ -2,7 +2,10 @@ import { COLOR_DEFAULT, downsample, isRgb, unpackRgb } from '@textui/core';
2
2
  import { ATTR_BLINK, ATTR_BOLD, ATTR_DIM, ATTR_HIDDEN, ATTR_INVERSE, ATTR_ITALIC, ATTR_STRIKE, ATTR_UNDERLINE, } from '@textui/core';
3
3
  import * as ansi from './ansi.js';
4
4
  function freshState() {
5
- return { fg: COLOR_DEFAULT, bg: COLOR_DEFAULT, attrs: 0, link: undefined, x: -1, y: -1, valid: false };
5
+ return {
6
+ fg: COLOR_DEFAULT, bg: COLOR_DEFAULT, attrs: 0, link: undefined,
7
+ x: -1, y: -1, valid: false, cursorShown: false,
8
+ };
6
9
  }
7
10
  export function fgSequence(color, depth) {
8
11
  const c = downsample(color, depth);
@@ -61,7 +64,9 @@ export class Writer {
61
64
  }
62
65
  /** Encode a frame. Returns an empty string when nothing changed. */
63
66
  write(frame) {
64
- if (frame.runs.length === 0 && !frame.cursor)
67
+ // Nothing to paint, nowhere to put the caret, and none on screen to take
68
+ // away. A frame that has to *hide* one still has work to do.
69
+ if (frame.runs.length === 0 && !frame.cursor && !this.state.cursorShown)
65
70
  return '';
66
71
  const out = [];
67
72
  const sync = this.capabilities.synchronizedOutput;
@@ -69,9 +74,12 @@ export class Writer {
69
74
  out.push(ansi.syncStart);
70
75
  // A frame is painted with the cursor hidden; showing it once at the end
71
76
  // is the difference between a steady caret and one that streaks.
72
- const hideForPaint = this.capabilities.cursor && frame.runs.length > 0;
73
- if (hideForPaint)
77
+ const hideForPaint = this.capabilities.cursor
78
+ && (frame.runs.length > 0 || (this.state.cursorShown && !frame.cursor));
79
+ if (hideForPaint) {
74
80
  out.push(ansi.cursorHide);
81
+ this.state.cursorShown = false;
82
+ }
75
83
  for (const run of frame.runs)
76
84
  out.push(this.encodeRun(run));
77
85
  if (this.state.link !== undefined) {
@@ -82,8 +90,10 @@ export class Writer {
82
90
  out.push(ansi.cursorTo(frame.cursor.x, frame.cursor.y));
83
91
  this.state.x = frame.cursor.x;
84
92
  this.state.y = frame.cursor.y;
85
- if (frame.cursor.visible)
93
+ if (frame.cursor.visible) {
86
94
  out.push(ansi.cursorShow);
95
+ this.state.cursorShown = true;
96
+ }
87
97
  }
88
98
  if (sync)
89
99
  out.push(ansi.syncEnd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textui/terminal",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Terminal adapters, capability detection, ANSI writing and input decoding for TextUI",
5
5
  "keywords": [
6
6
  "terminal",
@@ -54,7 +54,7 @@
54
54
  "test": "vitest run"
55
55
  },
56
56
  "dependencies": {
57
- "@textui/core": "^0.2.0"
57
+ "@textui/core": "^0.4.0"
58
58
  },
59
59
  "publishConfig": {
60
60
  "access": "public"
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ export * as ansi from './ansi.js';
2
2
  export { Writer, createWriter } from './writer.js';
3
3
  export { captureBuffer } from './capture.js';
4
4
  export type { CaptureOptions } from './capture.js';
5
+ export { renderStill } from './still.js';
6
+ export type { Still, StillOptions } from './still.js';
5
7
  export { bufferToSvg } from './svg.js';
6
8
  export type { SvgOptions } from './svg.js';
7
9
  export {
package/src/still.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type {
2
+ CapabilityOverrides, CellBuffer, CreateAppOptions, TextUIApp,
3
+ } from '@textui/core';
4
+ import { WRITER_KEY, createApp } from '@textui/core';
5
+ import { createVirtualTerminal } from './virtual.js';
6
+ import { createWriter } from './writer.js';
7
+ import { captureBuffer } from './capture.js';
8
+ import type { CaptureOptions } from './capture.js';
9
+
10
+ /**
11
+ * One frame of an application, as text.
12
+ *
13
+ * The same application, not a second rendering path: it is mounted against a
14
+ * terminal that is a size and nothing else, rendered until it stops changing,
15
+ * and every cell it painted is what comes back. Which is what a program does
16
+ * when its output is a pipe rather than a screen - there is no frame after
17
+ * this one to correct it, so it has to be the finished picture.
18
+ *
19
+ * Every example in this repository had written this out by hand, and all of
20
+ * them ended the same way: a sleep loop of four milliseconds times a number
21
+ * somebody had tried until the picture looked right. Eight, mostly - four in
22
+ * one, twelve in another - for the same eight lines of setup either side. The
23
+ * number was the tell. Nobody knew it, a small one silently writes a
24
+ * half-drawn frame, and `TextUIApp.settled` is the answer they were all
25
+ * approximating.
26
+ */
27
+ export interface StillOptions extends Omit<CreateAppOptions, 'terminal' | 'session'> {
28
+ /** Columns. The terminal is only a size, so this is the whole of it. */
29
+ width?: number;
30
+ height?: number;
31
+ /** What the terminal should claim to be able to do. */
32
+ capabilities?: CapabilityOverrides;
33
+ /**
34
+ * Drive it before the frame is taken.
35
+ *
36
+ * A still of an application in its opening state is the least interesting
37
+ * one. This is where a screen is pushed, a message sent, or a scripted host
38
+ * pumped to the point worth photographing - and it may be async, because
39
+ * most of those are.
40
+ */
41
+ before?(app: TextUIApp): void | Promise<void>;
42
+ /**
43
+ * The frame is drawn and the application is still alive.
44
+ *
45
+ * For what a string cannot carry and `stop` would take away: the buffer, to
46
+ * crop it to the rows that were used, and the theme, whose two colours are
47
+ * the honest background for a picture of *this* screen rather than the
48
+ * exporter's guess. It runs before the capture, so a buffer changed here is
49
+ * the buffer that comes back as `text`.
50
+ */
51
+ after?(app: TextUIApp): void | Promise<void>;
52
+ /** Plain text or SGR, and at what depth. Defaults to what the terminal claims. */
53
+ capture?: CaptureOptions;
54
+ /** Settle passes before giving up. See `TextUIApp.settled`. */
55
+ settleLimit?: number;
56
+ }
57
+
58
+ export interface Still {
59
+ /** Every cell it painted, rows separated by newlines. */
60
+ text: string;
61
+ /**
62
+ * The cells themselves, for anything text cannot carry - `bufferToSvg`, a
63
+ * pixel diff, an assertion about one cell's colour. Valid after the
64
+ * application has stopped: nothing writes to it again.
65
+ */
66
+ buffer: CellBuffer;
67
+ /**
68
+ * Whether it went quiet, or the limit ran out first.
69
+ *
70
+ * An application that animates settles between its frames, so this is
71
+ * `true` for one of those and the picture is a photograph of something
72
+ * moving. `false` means the passes never stopped producing work - a render
73
+ * loop that does not converge - and the frame was taken anyway, because a
74
+ * still is better evidence of that than nothing is.
75
+ */
76
+ settled: boolean;
77
+ }
78
+
79
+ export async function renderStill(options: StillOptions = {}): Promise<Still> {
80
+ const {
81
+ width = 80, height = 24, capabilities, before, after, capture, settleLimit, ...app
82
+ } = options;
83
+
84
+ const terminal = createVirtualTerminal({
85
+ width,
86
+ height,
87
+ ...(capabilities ? { capabilities } : {}),
88
+ });
89
+
90
+ const created = createApp({ ...app, terminal });
91
+ // The writer is provided even though the buffer is what is read: a virtual
92
+ // terminal records what was written to it, and a still that quietly stopped
93
+ // producing that would break anything reading the bytes rather than the
94
+ // cells.
95
+ created.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
96
+
97
+ await created.start();
98
+ await before?.(created);
99
+
100
+ const settled = await created.settled(settleLimit === undefined ? {} : { limit: settleLimit });
101
+ // Even when it did not settle: the limit means "stop waiting", not "give up
102
+ // on the frame", and a still of a moving thing is still a still.
103
+ created.flush();
104
+ await after?.(created);
105
+
106
+ const text = captureBuffer(created.buffer(), terminal.capabilities(), capture ?? {});
107
+ const buffer = created.buffer();
108
+ await created.stop();
109
+
110
+ return { text, buffer, settled };
111
+ }
package/src/writer.ts CHANGED
@@ -23,10 +23,22 @@ interface WriterState {
23
23
  x: number;
24
24
  y: number;
25
25
  valid: boolean;
26
+ /**
27
+ * Whether the terminal is currently showing its cursor.
28
+ *
29
+ * Tracked because a frame can need the cursor *taken away* without painting
30
+ * anything: focus moving from a text field to a control that has no caret
31
+ * changes no cell the field did not already own, and the cursor was left
32
+ * sitting in the field somebody had just tabbed out of.
33
+ */
34
+ cursorShown: boolean;
26
35
  }
27
36
 
28
37
  function freshState(): WriterState {
29
- return { fg: COLOR_DEFAULT, bg: COLOR_DEFAULT, attrs: 0, link: undefined, x: -1, y: -1, valid: false };
38
+ return {
39
+ fg: COLOR_DEFAULT, bg: COLOR_DEFAULT, attrs: 0, link: undefined,
40
+ x: -1, y: -1, valid: false, cursorShown: false,
41
+ };
30
42
  }
31
43
 
32
44
  export function fgSequence(color: number, depth: TerminalCapabilities['colorDepth']): string {
@@ -85,7 +97,9 @@ export class Writer {
85
97
 
86
98
  /** Encode a frame. Returns an empty string when nothing changed. */
87
99
  write(frame: Frame): string {
88
- if (frame.runs.length === 0 && !frame.cursor) return '';
100
+ // Nothing to paint, nowhere to put the caret, and none on screen to take
101
+ // away. A frame that has to *hide* one still has work to do.
102
+ if (frame.runs.length === 0 && !frame.cursor && !this.state.cursorShown) return '';
89
103
 
90
104
  const out: string[] = [];
91
105
  const sync = this.capabilities.synchronizedOutput;
@@ -93,8 +107,12 @@ export class Writer {
93
107
 
94
108
  // A frame is painted with the cursor hidden; showing it once at the end
95
109
  // is the difference between a steady caret and one that streaks.
96
- const hideForPaint = this.capabilities.cursor && frame.runs.length > 0;
97
- if (hideForPaint) out.push(ansi.cursorHide);
110
+ const hideForPaint = this.capabilities.cursor
111
+ && (frame.runs.length > 0 || (this.state.cursorShown && !frame.cursor));
112
+ if (hideForPaint) {
113
+ out.push(ansi.cursorHide);
114
+ this.state.cursorShown = false;
115
+ }
98
116
 
99
117
  for (const run of frame.runs) out.push(this.encodeRun(run));
100
118
 
@@ -107,7 +125,10 @@ export class Writer {
107
125
  out.push(ansi.cursorTo(frame.cursor.x, frame.cursor.y));
108
126
  this.state.x = frame.cursor.x;
109
127
  this.state.y = frame.cursor.y;
110
- if (frame.cursor.visible) out.push(ansi.cursorShow);
128
+ if (frame.cursor.visible) {
129
+ out.push(ansi.cursorShow);
130
+ this.state.cursorShown = true;
131
+ }
111
132
  }
112
133
 
113
134
  if (sync) out.push(ansi.syncEnd);