@giovannijecha/jecode 0.1.5-rc.2
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 +264 -0
- package/bin/jecode.js +11 -0
- package/dist/atomic.js +78 -0
- package/dist/batch-view.js +17 -0
- package/dist/batch.js +100 -0
- package/dist/cli-info.js +40 -0
- package/dist/commands.js +171 -0
- package/dist/config.js +97 -0
- package/dist/controller.js +90 -0
- package/dist/credential-commands.js +134 -0
- package/dist/credential-safety.js +86 -0
- package/dist/credentials.js +124 -0
- package/dist/main.js +7 -0
- package/dist/ollama-settings-command.js +75 -0
- package/dist/prompt.js +29 -0
- package/dist/provider-commands.js +236 -0
- package/dist/provider-errors.js +10 -0
- package/dist/providers/anthropic-stream.js +111 -0
- package/dist/providers/anthropic-wire.js +79 -0
- package/dist/providers/anthropic.js +77 -0
- package/dist/providers/catalog.js +37 -0
- package/dist/providers/http.js +219 -0
- package/dist/providers/index.js +18 -0
- package/dist/providers/ollama-endpoint.js +40 -0
- package/dist/providers/ollama-stream.js +60 -0
- package/dist/providers/ollama-wire.js +95 -0
- package/dist/providers/ollama.js +87 -0
- package/dist/providers/openai-stream.js +48 -0
- package/dist/providers/openai-wire.js +112 -0
- package/dist/providers/openai.js +70 -0
- package/dist/providers/sse.js +81 -0
- package/dist/providers/stream-limits.js +11 -0
- package/dist/session.js +3 -0
- package/dist/settings-command.js +202 -0
- package/dist/settings.js +98 -0
- package/dist/start.js +47 -0
- package/dist/tools/args.js +38 -0
- package/dist/tools/fs.js +277 -0
- package/dist/tools/index.js +39 -0
- package/dist/tools/paths.js +122 -0
- package/dist/tools/search.js +213 -0
- package/dist/tools/shell.js +139 -0
- package/dist/tools/text-boundary.js +102 -0
- package/dist/tools/types.js +1 -0
- package/dist/transcript-export.js +11 -0
- package/dist/transcript.js +49 -0
- package/dist/tui/activity.js +11 -0
- package/dist/tui/app-input.js +155 -0
- package/dist/tui/app-state.js +17 -0
- package/dist/tui/app-workflows.js +105 -0
- package/dist/tui/app.js +215 -0
- package/dist/tui/approve.js +67 -0
- package/dist/tui/blocks.js +23 -0
- package/dist/tui/complete.js +54 -0
- package/dist/tui/components/command-menu.js +17 -0
- package/dist/tui/components/composer.js +47 -0
- package/dist/tui/components/dock.js +10 -0
- package/dist/tui/components/footer.js +21 -0
- package/dist/tui/components/menu.js +38 -0
- package/dist/tui/components/messages.js +43 -0
- package/dist/tui/components/misc.js +22 -0
- package/dist/tui/components/status.js +45 -0
- package/dist/tui/components/tool.js +85 -0
- package/dist/tui/components/types.js +1 -0
- package/dist/tui/editor.js +105 -0
- package/dist/tui/feedback.js +74 -0
- package/dist/tui/field.js +60 -0
- package/dist/tui/frame.js +33 -0
- package/dist/tui/input.js +52 -0
- package/dist/tui/keys.js +177 -0
- package/dist/tui/modal.js +24 -0
- package/dist/tui/overlay.js +93 -0
- package/dist/tui/picker.js +99 -0
- package/dist/tui/screen.js +94 -0
- package/dist/tui/scroll.js +7 -0
- package/dist/tui/session-view.js +49 -0
- package/dist/tui/transcript-view.js +134 -0
- package/dist/tui/turn.js +255 -0
- package/dist/tui/view.js +88 -0
- package/dist/tui/workspace.js +59 -0
- package/dist/types.js +9 -0
- package/dist/ui/diff.js +109 -0
- package/dist/ui/highlight.js +158 -0
- package/dist/ui/inline.js +39 -0
- package/dist/ui/markdown.js +147 -0
- package/dist/ui/render.js +232 -0
- package/dist/ui/table.js +127 -0
- package/dist/ui/terminal-text.js +42 -0
- package/dist/ui/theme.js +25 -0
- package/dist/ui/width.js +196 -0
- package/dist/usage.js +30 -0
- package/dist/user-data.js +29 -0
- package/package.json +56 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { blank, row } from "../../ui/render.js";
|
|
2
|
+
const PAD = 1;
|
|
3
|
+
const PREVIEW_ROWS = 10;
|
|
4
|
+
export function renderTool(block, width, pal) {
|
|
5
|
+
const ground = toolGround(block.tone, pal);
|
|
6
|
+
const all = block.body ?? [];
|
|
7
|
+
const shown = block.expanded === true ? all : all.slice(0, PREVIEW_ROWS);
|
|
8
|
+
const remaining = all.length - shown.length;
|
|
9
|
+
const rows = [
|
|
10
|
+
"",
|
|
11
|
+
blank(width, ground),
|
|
12
|
+
row(width, [
|
|
13
|
+
{ text: block.name, fg: pal.ink.bright, bold: true },
|
|
14
|
+
...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.accent }]),
|
|
15
|
+
], [], ground, PAD),
|
|
16
|
+
];
|
|
17
|
+
if (shown.length > 0) {
|
|
18
|
+
rows.push(blank(width, ground));
|
|
19
|
+
rows.push(...shown.map((detail) => renderDetail(detail, block.tone, width, ground, pal)));
|
|
20
|
+
}
|
|
21
|
+
if (remaining > 0) {
|
|
22
|
+
rows.push(row(width, [{ text: `… ${remaining} more lines · ctrl+o expand`, fg: pal.ink.muted }], [], ground, PAD));
|
|
23
|
+
}
|
|
24
|
+
if (block.right !== "") {
|
|
25
|
+
rows.push(row(width, [{ text: resultLabel(block.right, block.tone), fg: statusInk(block.tone, pal) }], [], ground, PAD));
|
|
26
|
+
}
|
|
27
|
+
rows.push(blank(width, ground));
|
|
28
|
+
return rows;
|
|
29
|
+
}
|
|
30
|
+
function renderDetail(detail, tone, width, ground, pal) {
|
|
31
|
+
if (detail.kind === "out") {
|
|
32
|
+
const fg = tone === "fail" || failureLine(detail.text) ? pal.ink.removed : pal.ink.muted;
|
|
33
|
+
return row(width, [{ text: detail.text === "" ? " " : detail.text, fg }], [], ground, PAD);
|
|
34
|
+
}
|
|
35
|
+
if (detail.kind === "gap") {
|
|
36
|
+
return row(width, [{ text: ` ${detail.text}`, fg: pal.ink.muted }], [], ground, PAD);
|
|
37
|
+
}
|
|
38
|
+
const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
|
|
39
|
+
const prefix = `${detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " "}${String(number ?? "").padStart(3)} `;
|
|
40
|
+
const fg = detail.kind === "add"
|
|
41
|
+
? pal.ink.added
|
|
42
|
+
: detail.kind === "del"
|
|
43
|
+
? pal.ink.removed
|
|
44
|
+
: pal.ink.muted;
|
|
45
|
+
return row(width, [{ text: prefix, fg }, ...emphasized(detail.text, detail.emphasis, fg)], [], ground, PAD);
|
|
46
|
+
}
|
|
47
|
+
function emphasized(text, emphasis, fg) {
|
|
48
|
+
if (emphasis === undefined || emphasis.length <= 0)
|
|
49
|
+
return [{ text, fg }];
|
|
50
|
+
const start = Math.max(0, Math.min(text.length, emphasis.start));
|
|
51
|
+
const end = Math.max(start, Math.min(text.length, start + emphasis.length));
|
|
52
|
+
return [
|
|
53
|
+
...(start === 0 ? [] : [{ text: text.slice(0, start), fg }]),
|
|
54
|
+
{ text: text.slice(start, end), fg, inverse: true },
|
|
55
|
+
...(end === text.length ? [] : [{ text: text.slice(end), fg }]),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
function toolGround(tone, pal) {
|
|
59
|
+
if (tone === "ok")
|
|
60
|
+
return pal.surface.added;
|
|
61
|
+
if (tone === "fail")
|
|
62
|
+
return pal.surface.removed;
|
|
63
|
+
if (tone === "deny")
|
|
64
|
+
return pal.surface.attention;
|
|
65
|
+
return pal.surface.inset;
|
|
66
|
+
}
|
|
67
|
+
function statusInk(tone, pal) {
|
|
68
|
+
if (tone === "fail")
|
|
69
|
+
return pal.ink.removed;
|
|
70
|
+
if (tone === "pending")
|
|
71
|
+
return pal.ink.attention;
|
|
72
|
+
return pal.ink.muted;
|
|
73
|
+
}
|
|
74
|
+
function failureLine(line) {
|
|
75
|
+
return line.startsWith("✖") || line.includes("AssertionError") || /\bfailed\b/i.test(line);
|
|
76
|
+
}
|
|
77
|
+
function resultLabel(result, tone) {
|
|
78
|
+
if (tone === "pending")
|
|
79
|
+
return result;
|
|
80
|
+
const match = /^(.*?)(?:\s*·\s*)?(\d+(?:\.\d+)?(?:ms|s))$/.exec(result);
|
|
81
|
+
if (match === null)
|
|
82
|
+
return result;
|
|
83
|
+
const prefix = (match[1] ?? "").trim();
|
|
84
|
+
return `${prefix === "" ? "" : `${prefix} · `}Took ${match[2]}`;
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The input line: text and a cursor, plus the edits a terminal user expects.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions over an immutable state — no rendering, no I/O — so the whole
|
|
4
|
+
// line editor is testable without a terminal.
|
|
5
|
+
import { graphemes } from "../ui/width.js";
|
|
6
|
+
export const EMPTY = { text: "", cursor: 0 };
|
|
7
|
+
export function of(text) {
|
|
8
|
+
return { text, cursor: text.length };
|
|
9
|
+
}
|
|
10
|
+
export function insert(state, chunk) {
|
|
11
|
+
return {
|
|
12
|
+
text: state.text.slice(0, state.cursor) + chunk + state.text.slice(state.cursor),
|
|
13
|
+
cursor: state.cursor + chunk.length,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function backspace(state) {
|
|
17
|
+
if (state.cursor === 0)
|
|
18
|
+
return state;
|
|
19
|
+
const from = before(state.text, state.cursor);
|
|
20
|
+
return { text: state.text.slice(0, from) + state.text.slice(state.cursor), cursor: from };
|
|
21
|
+
}
|
|
22
|
+
export function del(state) {
|
|
23
|
+
if (state.cursor >= state.text.length)
|
|
24
|
+
return state;
|
|
25
|
+
const to = after(state.text, state.cursor);
|
|
26
|
+
return { text: state.text.slice(0, state.cursor) + state.text.slice(to), cursor: state.cursor };
|
|
27
|
+
}
|
|
28
|
+
export function left(state) {
|
|
29
|
+
return state.cursor === 0 ? state : { ...state, cursor: before(state.text, state.cursor) };
|
|
30
|
+
}
|
|
31
|
+
export function right(state) {
|
|
32
|
+
return state.cursor >= state.text.length
|
|
33
|
+
? state
|
|
34
|
+
: { ...state, cursor: after(state.text, state.cursor) };
|
|
35
|
+
}
|
|
36
|
+
export function home(state) {
|
|
37
|
+
return { ...state, cursor: 0 };
|
|
38
|
+
}
|
|
39
|
+
export function end(state) {
|
|
40
|
+
return { ...state, cursor: state.text.length };
|
|
41
|
+
}
|
|
42
|
+
export function wordLeft(state) {
|
|
43
|
+
return { ...state, cursor: startOfWordBefore(state.text, state.cursor) };
|
|
44
|
+
}
|
|
45
|
+
export function wordRight(state) {
|
|
46
|
+
return { ...state, cursor: endOfWordAfter(state.text, state.cursor) };
|
|
47
|
+
}
|
|
48
|
+
/** ctrl+w — delete the word behind the cursor. */
|
|
49
|
+
export function killWord(state) {
|
|
50
|
+
const from = startOfWordBefore(state.text, state.cursor);
|
|
51
|
+
if (from === state.cursor)
|
|
52
|
+
return state;
|
|
53
|
+
return { text: state.text.slice(0, from) + state.text.slice(state.cursor), cursor: from };
|
|
54
|
+
}
|
|
55
|
+
/** ctrl+u — delete everything behind the cursor. */
|
|
56
|
+
export function killToStart(state) {
|
|
57
|
+
return { text: state.text.slice(state.cursor), cursor: 0 };
|
|
58
|
+
}
|
|
59
|
+
/** ctrl+k — delete everything ahead of the cursor. */
|
|
60
|
+
export function killToEnd(state) {
|
|
61
|
+
return { text: state.text.slice(0, state.cursor), cursor: state.cursor };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Cursor motion is by grapheme, never by code unit.
|
|
65
|
+
*
|
|
66
|
+
* An emoji is two code units and an accented letter can be two more; stepping
|
|
67
|
+
* over half of either does not move the cursor one place to the left, it
|
|
68
|
+
* breaks the string — and a lone surrogate is what the terminal then draws.
|
|
69
|
+
*/
|
|
70
|
+
function before(text, cursor) {
|
|
71
|
+
let at = 0;
|
|
72
|
+
for (const cluster of graphemes(text)) {
|
|
73
|
+
const next = at + cluster.length;
|
|
74
|
+
if (next >= cursor)
|
|
75
|
+
return at;
|
|
76
|
+
at = next;
|
|
77
|
+
}
|
|
78
|
+
return at;
|
|
79
|
+
}
|
|
80
|
+
function after(text, cursor) {
|
|
81
|
+
let at = 0;
|
|
82
|
+
for (const cluster of graphemes(text)) {
|
|
83
|
+
const next = at + cluster.length;
|
|
84
|
+
if (at >= cursor)
|
|
85
|
+
return next;
|
|
86
|
+
at = next;
|
|
87
|
+
}
|
|
88
|
+
return text.length;
|
|
89
|
+
}
|
|
90
|
+
function startOfWordBefore(text, cursor) {
|
|
91
|
+
let i = cursor;
|
|
92
|
+
while (i > 0 && text[i - 1] === " ")
|
|
93
|
+
i--;
|
|
94
|
+
while (i > 0 && text[i - 1] !== " ")
|
|
95
|
+
i--;
|
|
96
|
+
return i;
|
|
97
|
+
}
|
|
98
|
+
function endOfWordAfter(text, cursor) {
|
|
99
|
+
let i = cursor;
|
|
100
|
+
while (i < text.length && text[i] === " ")
|
|
101
|
+
i++;
|
|
102
|
+
while (i < text.length && text[i] !== " ")
|
|
103
|
+
i++;
|
|
104
|
+
return i;
|
|
105
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Operational feedback belongs in the footer, not in the conversation.
|
|
2
|
+
const INFO_MS = 2_800;
|
|
3
|
+
const WARN_MS = 4_200;
|
|
4
|
+
const ERROR_MS = 6_000;
|
|
5
|
+
/** Own replacement and expiry without leaking timers into the TUI shell. */
|
|
6
|
+
export function feedbackController(changed) {
|
|
7
|
+
let current;
|
|
8
|
+
let timer;
|
|
9
|
+
const clearTimer = () => {
|
|
10
|
+
if (timer !== undefined)
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
timer = undefined;
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
show(feedback) {
|
|
16
|
+
clearTimer();
|
|
17
|
+
current = feedback;
|
|
18
|
+
changed(feedback);
|
|
19
|
+
if (feedback.timeoutMs === undefined)
|
|
20
|
+
return;
|
|
21
|
+
timer = setTimeout(() => {
|
|
22
|
+
timer = undefined;
|
|
23
|
+
if (current !== feedback)
|
|
24
|
+
return;
|
|
25
|
+
current = undefined;
|
|
26
|
+
changed(undefined);
|
|
27
|
+
}, feedback.timeoutMs);
|
|
28
|
+
},
|
|
29
|
+
dismiss() {
|
|
30
|
+
clearTimer();
|
|
31
|
+
if (current === undefined)
|
|
32
|
+
return;
|
|
33
|
+
current = undefined;
|
|
34
|
+
changed(undefined);
|
|
35
|
+
},
|
|
36
|
+
close() {
|
|
37
|
+
clearTimer();
|
|
38
|
+
current = undefined;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Turn a command notice into one replaceable message in the footer status channel. */
|
|
43
|
+
export function commandFeedback(block) {
|
|
44
|
+
if (block.kind !== "notice")
|
|
45
|
+
return undefined;
|
|
46
|
+
return {
|
|
47
|
+
text: block.text,
|
|
48
|
+
tone: block.tone,
|
|
49
|
+
timeoutMs: block.tone === "info" ? INFO_MS : block.tone === "warn" ? WARN_MS : ERROR_MS,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Explain why a model turn cannot start, without exposing configuration internals. */
|
|
53
|
+
export function turnBlocker(session) {
|
|
54
|
+
const blocked = session.provider.blocked();
|
|
55
|
+
if (blocked !== undefined && !blocked.startsWith(`${session.provider.keyVar} `)) {
|
|
56
|
+
return { text: blocked, tone: "error" };
|
|
57
|
+
}
|
|
58
|
+
if (blocked !== undefined) {
|
|
59
|
+
return {
|
|
60
|
+
text: `${providerName(session.provider.id)} needs an API key · /settings`,
|
|
61
|
+
tone: "warn",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (session.model === "") {
|
|
65
|
+
return {
|
|
66
|
+
text: `${providerName(session.provider.id)} needs a model · /models`,
|
|
67
|
+
tone: "warn",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
function providerName(id) {
|
|
73
|
+
return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Typing one line into the dock, when what is being typed is not a message.
|
|
2
|
+
//
|
|
3
|
+
// The composer already knows how to be a line editor, so this borrows it whole
|
|
4
|
+
// — the same `Editor`, the same keymap — and adds the two things a composer
|
|
5
|
+
// must never do: hide what it holds, and stay on one row however long that is.
|
|
6
|
+
import { row } from "../ui/render.js";
|
|
7
|
+
import { graphemes, textWidth } from "../ui/width.js";
|
|
8
|
+
const DOT = "●";
|
|
9
|
+
const PROMPT = "→ ";
|
|
10
|
+
const LEAD = textWidth(PROMPT);
|
|
11
|
+
export function panel(field, width, pal) {
|
|
12
|
+
const { ink } = pal;
|
|
13
|
+
const head = row(width, field.title, field.right === undefined ? [] : [{ text: field.right, fg: ink.muted }]);
|
|
14
|
+
const view = laid(field, width);
|
|
15
|
+
const line = row(width, [
|
|
16
|
+
{ text: PROMPT, fg: pal.accent },
|
|
17
|
+
{ text: view.text, fg: ink.bright },
|
|
18
|
+
]);
|
|
19
|
+
const note = field.note === undefined ? [] : [row(width, [{ text: ` ${field.note}`, fg: ink.muted }])];
|
|
20
|
+
return [head, line, ...note];
|
|
21
|
+
}
|
|
22
|
+
/** Where the caret sits, relative to the unframed field body. */
|
|
23
|
+
export function caret(field, width) {
|
|
24
|
+
return { row: 1, col: LEAD + laid(field, width).col };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The visible slice of the line, and where the caret lands inside it.
|
|
28
|
+
*
|
|
29
|
+
* A key is longer than most terminals are wide, so the line scrolls under a
|
|
30
|
+
* fixed window rather than wrapping: an input that grows downwards pushes the
|
|
31
|
+
* rest of the dock around while it is being pasted into.
|
|
32
|
+
*/
|
|
33
|
+
function laid(field, width) {
|
|
34
|
+
const inner = Math.max(1, width - LEAD);
|
|
35
|
+
const all = field.secret ? DOT.repeat(size(field.editor.text)) : field.editor.text;
|
|
36
|
+
const at = size(field.editor.text.slice(0, field.editor.cursor));
|
|
37
|
+
// Keep the caret in view, and prefer showing the end of what was typed —
|
|
38
|
+
// which for a pasted key is the half that says the paste arrived whole.
|
|
39
|
+
const start = Math.max(0, at - inner + 1);
|
|
40
|
+
return { text: [...all].slice(start, start + inner).join(""), col: at - start };
|
|
41
|
+
}
|
|
42
|
+
function size(text) {
|
|
43
|
+
let n = 0;
|
|
44
|
+
for (const _cluster of graphemes(text))
|
|
45
|
+
n++;
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The line with its breaks taken out.
|
|
50
|
+
*
|
|
51
|
+
* A key is one line. A pasted one usually carries the newline that ended it,
|
|
52
|
+
* and a field that grows a second row on paste is a field that has already
|
|
53
|
+
* lost its layout — the break is the end of the paste, never content.
|
|
54
|
+
*/
|
|
55
|
+
export function oneLine(editor) {
|
|
56
|
+
if (!editor.text.includes("\n"))
|
|
57
|
+
return editor;
|
|
58
|
+
const kept = editor.text.slice(0, editor.cursor).replace(/\n/g, "");
|
|
59
|
+
return { text: editor.text.replace(/\n/g, ""), cursor: kept.length };
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Painting a frame by writing only the rows that changed.
|
|
2
|
+
//
|
|
3
|
+
// The whole frame is recomposed on every render — that is what makes the view
|
|
4
|
+
// a pure function of the state. What must not happen on every render is
|
|
5
|
+
// writing it: a full repaint at streaming speed flickers and floods the pipe.
|
|
6
|
+
// So the painter keeps the last frame and emits the difference.
|
|
7
|
+
import { CSI } from "../ui/render.js";
|
|
8
|
+
import { CURSOR, SYNC, write } from "./screen.js";
|
|
9
|
+
export function painter() {
|
|
10
|
+
let previous = [];
|
|
11
|
+
return {
|
|
12
|
+
paint(rows, cursor) {
|
|
13
|
+
let out = SYNC.begin + CURSOR.hide;
|
|
14
|
+
const height = Math.max(rows.length, previous.length);
|
|
15
|
+
for (let i = 0; i < height; i++) {
|
|
16
|
+
const next = rows[i] ?? "";
|
|
17
|
+
if (next === previous[i])
|
|
18
|
+
continue;
|
|
19
|
+
out += `${CSI}${i + 1};1H${CSI}2K${next}`;
|
|
20
|
+
}
|
|
21
|
+
if (cursor !== undefined) {
|
|
22
|
+
out += `${CSI}${cursor.row + 1};${cursor.col + 1}H${CURSOR.show}`;
|
|
23
|
+
}
|
|
24
|
+
previous = rows.slice();
|
|
25
|
+
// One write, so a terminal that ignores the synchronization hint still
|
|
26
|
+
// gets the frame as a single arrival rather than a row at a time.
|
|
27
|
+
write(out + SYNC.end);
|
|
28
|
+
},
|
|
29
|
+
invalidate() {
|
|
30
|
+
previous = [];
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Which key does which edit.
|
|
2
|
+
//
|
|
3
|
+
// Pure transforms over the input line, kept apart from the shell so that the
|
|
4
|
+
// keymap can be read — and changed — without reading the turn machinery.
|
|
5
|
+
import * as edit from "./editor.js";
|
|
6
|
+
import { terminalText } from "../ui/terminal-text.js";
|
|
7
|
+
/** The new line, or undefined when the key is not an editing key. */
|
|
8
|
+
export function applyKey(state, key) {
|
|
9
|
+
switch (key.name) {
|
|
10
|
+
case "char":
|
|
11
|
+
case "paste":
|
|
12
|
+
// A pasted CR is a line break, not a submit: the shell only submits on a
|
|
13
|
+
// keypress it saw as enter.
|
|
14
|
+
return edit.insert(state, terminalText(key.text.replace(/\r\n?/g, "\n"), { multiline: true }));
|
|
15
|
+
case "newline":
|
|
16
|
+
return edit.insert(state, "\n");
|
|
17
|
+
case "backspace":
|
|
18
|
+
return edit.backspace(state);
|
|
19
|
+
case "delete":
|
|
20
|
+
return edit.del(state);
|
|
21
|
+
case "left":
|
|
22
|
+
return edit.left(state);
|
|
23
|
+
case "right":
|
|
24
|
+
return edit.right(state);
|
|
25
|
+
case "wordleft":
|
|
26
|
+
return edit.wordLeft(state);
|
|
27
|
+
case "wordright":
|
|
28
|
+
return edit.wordRight(state);
|
|
29
|
+
case "home":
|
|
30
|
+
return edit.home(state);
|
|
31
|
+
case "end":
|
|
32
|
+
return edit.end(state);
|
|
33
|
+
default:
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
if (!key.ctrl)
|
|
37
|
+
return undefined;
|
|
38
|
+
switch (key.name) {
|
|
39
|
+
case "a":
|
|
40
|
+
return edit.home(state);
|
|
41
|
+
case "e":
|
|
42
|
+
return edit.end(state);
|
|
43
|
+
case "u":
|
|
44
|
+
return edit.killToStart(state);
|
|
45
|
+
case "k":
|
|
46
|
+
return edit.killToEnd(state);
|
|
47
|
+
case "w":
|
|
48
|
+
return edit.killWord(state);
|
|
49
|
+
default:
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/tui/keys.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Raw bytes from a terminal, turned into key events.
|
|
2
|
+
//
|
|
3
|
+
// Two things make this stateful rather than a pure function. An escape
|
|
4
|
+
// sequence can be split across reads, so an incomplete one is held rather than
|
|
5
|
+
// guessed at; and a bracketed paste arrives as a delimited run that must not be
|
|
6
|
+
// interpreted key by key, or a pasted newline submits half the paste.
|
|
7
|
+
const ESC = String.fromCharCode(27);
|
|
8
|
+
const DEL = String.fromCharCode(127);
|
|
9
|
+
const PASTE_START = "[200~";
|
|
10
|
+
const PASTE_END = "[201~";
|
|
11
|
+
// SGR mouse reporting (?1006) rather than the original X10 encoding: the old
|
|
12
|
+
// one packs the coordinates into single bytes and simply stops being able to
|
|
13
|
+
// say where the pointer is past column 223.
|
|
14
|
+
const MOUSE = /^\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
15
|
+
const BUTTONS = ["left", "middle", "right", "none"];
|
|
16
|
+
// Both the normal and the application-cursor forms, because a terminal sends
|
|
17
|
+
// either depending on the mode it thinks it is in.
|
|
18
|
+
const SEQUENCES = {
|
|
19
|
+
"[A": "up",
|
|
20
|
+
"[B": "down",
|
|
21
|
+
"[C": "right",
|
|
22
|
+
"[D": "left",
|
|
23
|
+
OA: "up",
|
|
24
|
+
OB: "down",
|
|
25
|
+
OC: "right",
|
|
26
|
+
OD: "left",
|
|
27
|
+
"[H": "home",
|
|
28
|
+
"[F": "end",
|
|
29
|
+
OH: "home",
|
|
30
|
+
OF: "end",
|
|
31
|
+
"[1~": "home",
|
|
32
|
+
"[4~": "end",
|
|
33
|
+
"[7~": "home",
|
|
34
|
+
"[8~": "end",
|
|
35
|
+
"[3~": "delete",
|
|
36
|
+
"[5~": "pageup",
|
|
37
|
+
"[6~": "pagedown",
|
|
38
|
+
"[1;5C": "wordright",
|
|
39
|
+
"[1;5D": "wordleft",
|
|
40
|
+
"[Z": "backtab",
|
|
41
|
+
// alt+enter, which arrives as an escape followed by the return itself. It is
|
|
42
|
+
// how a multi-line message gets written when enter is what sends one.
|
|
43
|
+
"\r": "newline",
|
|
44
|
+
};
|
|
45
|
+
const CONTROL = {
|
|
46
|
+
"\r": "enter",
|
|
47
|
+
"\n": "enter",
|
|
48
|
+
"\t": "tab",
|
|
49
|
+
"\b": "backspace",
|
|
50
|
+
[DEL]: "backspace",
|
|
51
|
+
};
|
|
52
|
+
export function decoder() {
|
|
53
|
+
let held = "";
|
|
54
|
+
let pasting = false;
|
|
55
|
+
let pasted = "";
|
|
56
|
+
const drain = (final) => {
|
|
57
|
+
const keys = [];
|
|
58
|
+
while (held !== "") {
|
|
59
|
+
if (pasting) {
|
|
60
|
+
const end = held.indexOf(ESC + PASTE_END);
|
|
61
|
+
if (end === -1) {
|
|
62
|
+
// Hold back a possible partial terminator rather than pasting it.
|
|
63
|
+
const safe = held.length - PASTE_END.length - 1;
|
|
64
|
+
if (safe > 0) {
|
|
65
|
+
pasted += held.slice(0, safe);
|
|
66
|
+
held = held.slice(safe);
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
pasted += held.slice(0, end);
|
|
71
|
+
held = held.slice(end + PASTE_END.length + 1);
|
|
72
|
+
pasting = false;
|
|
73
|
+
keys.push({ name: "paste", text: pasted, ctrl: false });
|
|
74
|
+
pasted = "";
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const ch = held[0];
|
|
78
|
+
if (ch === ESC) {
|
|
79
|
+
const rest = held.slice(1);
|
|
80
|
+
if (rest === "" && !final)
|
|
81
|
+
break;
|
|
82
|
+
if (rest.startsWith(PASTE_START)) {
|
|
83
|
+
held = rest.slice(PASTE_START.length);
|
|
84
|
+
pasting = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const mouse = MOUSE.exec(rest);
|
|
88
|
+
if (mouse !== null) {
|
|
89
|
+
held = rest.slice(mouse[0].length);
|
|
90
|
+
keys.push(pointerKey(mouse));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const match = matchSequence(rest);
|
|
94
|
+
if (match !== undefined) {
|
|
95
|
+
held = rest.slice(match.length);
|
|
96
|
+
keys.push({ name: SEQUENCES[match], text: "", ctrl: false });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!final && couldGrow(rest))
|
|
100
|
+
break;
|
|
101
|
+
held = held.slice(1);
|
|
102
|
+
keys.push({ name: "escape", text: "", ctrl: false });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const named = CONTROL[ch];
|
|
106
|
+
if (named !== undefined) {
|
|
107
|
+
held = held.slice(1);
|
|
108
|
+
keys.push({ name: named, text: "", ctrl: false });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// C0 controls other than the named ones are ctrl+letter.
|
|
112
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
113
|
+
if (code < 0x20) {
|
|
114
|
+
held = held.slice(1);
|
|
115
|
+
keys.push({ name: String.fromCharCode(code + 0x60), text: "", ctrl: true });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// Printable runs travel together: an unbracketed paste arrives this way,
|
|
119
|
+
// and inserting it one character at a time is pure overhead.
|
|
120
|
+
let end = 1;
|
|
121
|
+
while (end < held.length) {
|
|
122
|
+
const next = held[end];
|
|
123
|
+
if ((next.codePointAt(0) ?? 0) < 0x20 || next === ESC || next === DEL)
|
|
124
|
+
break;
|
|
125
|
+
end++;
|
|
126
|
+
}
|
|
127
|
+
keys.push({ name: "char", text: held.slice(0, end), ctrl: false });
|
|
128
|
+
held = held.slice(end);
|
|
129
|
+
}
|
|
130
|
+
return keys;
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
push(chunk) {
|
|
134
|
+
held += chunk;
|
|
135
|
+
return drain(false);
|
|
136
|
+
},
|
|
137
|
+
flush() {
|
|
138
|
+
return drain(true);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function matchSequence(rest) {
|
|
143
|
+
for (const seq of Object.keys(SEQUENCES)) {
|
|
144
|
+
if (rest.startsWith(seq))
|
|
145
|
+
return seq;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
/** Whether `rest` is still a viable prefix of something we know. */
|
|
150
|
+
function couldGrow(rest) {
|
|
151
|
+
if (PASTE_START.startsWith(rest))
|
|
152
|
+
return true;
|
|
153
|
+
// A mouse report has no fixed length, so it grows until its final letter.
|
|
154
|
+
if (/^\[<?\d*;?\d*;?\d*$/.test(rest))
|
|
155
|
+
return true;
|
|
156
|
+
return Object.keys(SEQUENCES).some((seq) => seq.startsWith(rest));
|
|
157
|
+
}
|
|
158
|
+
function pointerKey(match) {
|
|
159
|
+
const code = Number(match[1]);
|
|
160
|
+
const col = Number(match[2]) - 1;
|
|
161
|
+
const row = Number(match[3]) - 1;
|
|
162
|
+
const released = match[4] === "m";
|
|
163
|
+
const wheeling = (code & 64) !== 0;
|
|
164
|
+
const action = wheeling ? "wheel" : released ? "release" : (code & 32) !== 0 ? "move" : "press";
|
|
165
|
+
return {
|
|
166
|
+
name: "pointer",
|
|
167
|
+
text: "",
|
|
168
|
+
ctrl: (code & 16) !== 0,
|
|
169
|
+
pointer: {
|
|
170
|
+
action,
|
|
171
|
+
button: wheeling ? "none" : (BUTTONS[code & 3] ?? "none"),
|
|
172
|
+
wheel: wheeling ? ((code & 1) === 0 ? "up" : "down") : undefined,
|
|
173
|
+
col,
|
|
174
|
+
row,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// What can take the dock over, and how it draws.
|
|
2
|
+
//
|
|
3
|
+
// Two kinds, and the union exists so that everything between the command that
|
|
4
|
+
// opens one and the frame that draws it — the view, the shell, the key handler
|
|
5
|
+
// — speaks about "the thing that is open" rather than about a picker and a
|
|
6
|
+
// field separately. A third kind would be added here and nowhere else.
|
|
7
|
+
import * as picker from "./picker.js";
|
|
8
|
+
import * as field from "./field.js";
|
|
9
|
+
export function panel(modal, width, pal, maxRows) {
|
|
10
|
+
return modal.kind === "pick"
|
|
11
|
+
? picker.panel(modal.picker, width, pal, maxRows)
|
|
12
|
+
: maxRows === undefined
|
|
13
|
+
? field.panel(modal.field, width, pal)
|
|
14
|
+
: field.panel(modal.field, width, pal).slice(0, maxRows);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Where the caret goes while this is open, if it goes anywhere.
|
|
18
|
+
*
|
|
19
|
+
* A menu is read, not typed into, so it hides the caret entirely — a blinking
|
|
20
|
+
* block on a row nobody is editing is an invitation to type into it.
|
|
21
|
+
*/
|
|
22
|
+
export function caret(modal, width) {
|
|
23
|
+
return modal.kind === "type" ? field.caret(modal.field, width) : undefined;
|
|
24
|
+
}
|