@sigrun/tui 0.4.1 → 0.5.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/CHANGELOG.md +10 -1
- package/dist/actions.d.ts +119 -0
- package/dist/actions.js +12 -0
- package/dist/api.d.ts +209 -0
- package/dist/api.js +282 -0
- package/dist/blocks/braille.d.ts +41 -0
- package/dist/blocks/braille.js +131 -0
- package/dist/blocks/chart.d.ts +20 -0
- package/dist/blocks/chart.js +397 -0
- package/dist/blocks/chat-transport.d.ts +44 -0
- package/dist/blocks/chat-transport.js +116 -0
- package/dist/blocks/collection/calendar.d.ts +28 -0
- package/dist/blocks/collection/calendar.js +232 -0
- package/dist/blocks/collection/card.d.ts +36 -0
- package/dist/blocks/collection/card.js +128 -0
- package/dist/blocks/collection/collection.d.ts +9 -0
- package/dist/blocks/collection/collection.js +26 -0
- package/dist/blocks/collection/grid.d.ts +18 -0
- package/dist/blocks/collection/grid.js +72 -0
- package/dist/blocks/collection/lanes.d.ts +32 -0
- package/dist/blocks/collection/lanes.js +320 -0
- package/dist/blocks/collection/list.d.ts +30 -0
- package/dist/blocks/collection/list.js +273 -0
- package/dist/blocks/collection/move.d.ts +67 -0
- package/dist/blocks/collection/move.js +96 -0
- package/dist/blocks/collection/shared.d.ts +87 -0
- package/dist/blocks/collection/shared.js +177 -0
- package/dist/blocks/collection/table.d.ts +26 -0
- package/dist/blocks/collection/table.js +221 -0
- package/dist/blocks/content.d.ts +48 -0
- package/dist/blocks/content.js +253 -0
- package/dist/blocks/context.d.ts +117 -0
- package/dist/blocks/context.js +65 -0
- package/dist/blocks/diff.d.ts +21 -0
- package/dist/blocks/diff.js +25 -0
- package/dist/blocks/ext-transport.d.ts +70 -0
- package/dist/blocks/ext-transport.js +165 -0
- package/dist/blocks/form/choice.d.ts +54 -0
- package/dist/blocks/form/choice.js +175 -0
- package/dist/blocks/form/fields.d.ts +56 -0
- package/dist/blocks/form/fields.js +136 -0
- package/dist/blocks/form/file.d.ts +61 -0
- package/dist/blocks/form/file.js +121 -0
- package/dist/blocks/form/form.d.ts +66 -0
- package/dist/blocks/form/form.js +764 -0
- package/dist/blocks/form/list.d.ts +37 -0
- package/dist/blocks/form/list.js +70 -0
- package/dist/blocks/form/values.d.ts +20 -0
- package/dist/blocks/form/values.js +106 -0
- package/dist/blocks/icons.d.ts +14 -0
- package/dist/blocks/icons.js +74 -0
- package/dist/blocks/image.d.ts +39 -0
- package/dist/blocks/image.js +82 -0
- package/dist/blocks/interaction-transport.d.ts +42 -0
- package/dist/blocks/interaction-transport.js +79 -0
- package/dist/blocks/layout.d.ts +31 -0
- package/dist/blocks/layout.js +155 -0
- package/dist/blocks/log.d.ts +24 -0
- package/dist/blocks/log.js +111 -0
- package/dist/blocks/meter.d.ts +16 -0
- package/dist/blocks/meter.js +50 -0
- package/dist/blocks/page.d.ts +54 -0
- package/dist/blocks/page.js +360 -0
- package/dist/blocks/render.d.ts +31 -0
- package/dist/blocks/render.js +163 -0
- package/dist/blocks/surface-view.d.ts +41 -0
- package/dist/blocks/surface-view.js +137 -0
- package/dist/blocks/surface.d.ts +80 -0
- package/dist/blocks/surface.js +506 -0
- package/dist/blocks/tones.d.ts +23 -0
- package/dist/blocks/tones.js +63 -0
- package/dist/blocks/treeview.d.ts +22 -0
- package/dist/blocks/treeview.js +121 -0
- package/dist/blocks/width.d.ts +32 -0
- package/dist/blocks/width.js +70 -0
- package/dist/clipboard.d.ts +20 -0
- package/dist/clipboard.js +19 -0
- package/dist/editor-launch.d.ts +34 -0
- package/dist/editor-launch.js +70 -0
- package/dist/keymaps/emacs.d.ts +18 -0
- package/dist/keymaps/emacs.js +172 -0
- package/dist/keymaps/standard.d.ts +17 -0
- package/dist/keymaps/standard.js +175 -0
- package/dist/keymaps/types.d.ts +43 -0
- package/dist/keymaps/types.js +28 -0
- package/dist/keymaps/vim.d.ts +48 -0
- package/dist/keymaps/vim.js +291 -0
- package/dist/launch.d.ts +159 -0
- package/dist/launch.js +478 -0
- package/dist/sse.d.ts +112 -0
- package/dist/sse.js +418 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +119 -0
- package/dist/ui/ambient.d.ts +78 -0
- package/dist/ui/ambient.js +264 -0
- package/dist/ui/app.d.ts +319 -0
- package/dist/ui/app.js +1539 -0
- package/dist/ui/composer.d.ts +66 -0
- package/dist/ui/composer.js +133 -0
- package/dist/ui/conversation-pane.d.ts +143 -0
- package/dist/ui/conversation-pane.js +615 -0
- package/dist/ui/help.d.ts +15 -0
- package/dist/ui/help.js +80 -0
- package/dist/ui/interactions.d.ts +91 -0
- package/dist/ui/interactions.js +477 -0
- package/dist/ui/picker.d.ts +26 -0
- package/dist/ui/picker.js +130 -0
- package/dist/ui/settings.d.ts +68 -0
- package/dist/ui/settings.js +254 -0
- package/dist/ui/sidebar.d.ts +99 -0
- package/dist/ui/sidebar.js +269 -0
- package/dist/ui/status-bar.d.ts +77 -0
- package/dist/ui/status-bar.js +146 -0
- package/package.json +4 -3
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `tree` block (UI-blocks spec §5.3): a `nodes` binding of
|
|
3
|
+
* `{id, label, icon?, hasChildren?, children?}[]` with a cursor row,
|
|
4
|
+
* arrow-key movement, and Enter to expand/collapse or activate.
|
|
5
|
+
*
|
|
6
|
+
* A node with inline `children` toggles locally and never dispatches;
|
|
7
|
+
* a node with `hasChildren` and no inline children dispatches
|
|
8
|
+
* `onExpand` (an `edit`-shaped action carrying the handler NAME — the
|
|
9
|
+
* handler returns `DataOps` inserting the children). A leaf runs
|
|
10
|
+
* `onActivate` with `{id}`. Markers: `▸`/`▾` (ASCII `>`/`v`).
|
|
11
|
+
*/
|
|
12
|
+
import { bindRaw } from "./context.js";
|
|
13
|
+
import { iconGlyph } from "./icons.js";
|
|
14
|
+
import { truncate } from "./width.js";
|
|
15
|
+
/** The markers for expandable nodes. */
|
|
16
|
+
const MARKERS = { collapsed: "▸", expanded: "▾", collapsedAscii: ">", expandedAscii: "v" };
|
|
17
|
+
/** Read the bound nodes, dropping malformed entries. */
|
|
18
|
+
function treeNodes(block, ctx) {
|
|
19
|
+
const raw = bindRaw(block.nodes, ctx);
|
|
20
|
+
if (!Array.isArray(raw))
|
|
21
|
+
return [];
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const entry of raw) {
|
|
24
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
25
|
+
continue;
|
|
26
|
+
const record = entry;
|
|
27
|
+
if (typeof record.id !== "string" || typeof record.label !== "string")
|
|
28
|
+
continue;
|
|
29
|
+
out.push({
|
|
30
|
+
id: record.id,
|
|
31
|
+
label: record.label,
|
|
32
|
+
icon: typeof record.icon === "string" ? record.icon : undefined,
|
|
33
|
+
hasChildren: record.hasChildren === true,
|
|
34
|
+
children: Array.isArray(record.children)
|
|
35
|
+
? record.children
|
|
36
|
+
: undefined,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** `tree`: cursor + expand/collapse + activate. */
|
|
42
|
+
export class TreeComponent {
|
|
43
|
+
#block;
|
|
44
|
+
#ctx;
|
|
45
|
+
#cursor = 0;
|
|
46
|
+
#expanded = new Set();
|
|
47
|
+
constructor(block, ctx) {
|
|
48
|
+
this.#block = block;
|
|
49
|
+
this.#ctx = ctx;
|
|
50
|
+
}
|
|
51
|
+
/** The visible rows (children of expanded nodes interleave). */
|
|
52
|
+
#rows() {
|
|
53
|
+
const out = [];
|
|
54
|
+
const walk = (nodes, depth) => {
|
|
55
|
+
for (const node of nodes) {
|
|
56
|
+
out.push({ node, depth });
|
|
57
|
+
if (this.#expanded.has(node.id) && node.children !== undefined) {
|
|
58
|
+
walk(node.children, depth + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
walk(treeNodes(this.#block, this.#ctx), 0);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
render(width) {
|
|
66
|
+
const unicode = this.#ctx.unicode;
|
|
67
|
+
return this.#rows().map((row, index) => {
|
|
68
|
+
const cursor = index === this.#cursor ? "> " : " ";
|
|
69
|
+
// Every row carries a state marker; leaves show the collapsed
|
|
70
|
+
// glyph as their bullet so icon rows align with parent rows.
|
|
71
|
+
const marker = (this.#expanded.has(row.node.id)
|
|
72
|
+
? unicode
|
|
73
|
+
? MARKERS.expanded
|
|
74
|
+
: MARKERS.expandedAscii
|
|
75
|
+
: unicode
|
|
76
|
+
? MARKERS.collapsed
|
|
77
|
+
: MARKERS.collapsedAscii) + " ";
|
|
78
|
+
const icon = row.node.icon === undefined
|
|
79
|
+
? ""
|
|
80
|
+
: `${iconGlyph(row.node.icon, !unicode)} `;
|
|
81
|
+
const indent = " ".repeat(row.depth);
|
|
82
|
+
return truncate(`${cursor}${indent}${marker}${icon}${row.node.label}`, width);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
handleInput(data) {
|
|
86
|
+
const rows = this.#rows();
|
|
87
|
+
if (data === "\x1b[B") {
|
|
88
|
+
this.#cursor = Math.min(rows.length - 1, this.#cursor + 1);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (data === "\x1b[A") {
|
|
92
|
+
this.#cursor = Math.max(0, this.#cursor - 1);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (data !== "\r")
|
|
96
|
+
return;
|
|
97
|
+
const row = rows[this.#cursor];
|
|
98
|
+
if (row === undefined)
|
|
99
|
+
return;
|
|
100
|
+
const node = row.node;
|
|
101
|
+
if (node.children !== undefined) {
|
|
102
|
+
// Inline children toggle locally; nothing dispatches.
|
|
103
|
+
if (this.#expanded.has(node.id))
|
|
104
|
+
this.#expanded.delete(node.id);
|
|
105
|
+
else
|
|
106
|
+
this.#expanded.add(node.id);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (node.hasChildren === true && this.#block.onExpand !== undefined) {
|
|
110
|
+
const action = { type: "edit", name: this.#block.onExpand };
|
|
111
|
+
this.#ctx.dispatch(action, { id: node.id });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (this.#block.onActivate !== undefined) {
|
|
115
|
+
this.#ctx.dispatch(this.#block.onActivate, { id: node.id });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
invalidate() {
|
|
119
|
+
// Cursor and expansion survive re-renders.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal width for block rendering (UI-blocks U2 Task 1): the width
|
|
3
|
+
* classes (spec §12.1), the `s`/`m`/`l` token scale (spec §5.2), and the
|
|
4
|
+
* clamp/pad helpers every block renderer funnels through.
|
|
5
|
+
*
|
|
6
|
+
* The display-width measure is pi-tui's `visibleWidth` (grapheme-aware,
|
|
7
|
+
* wcwidth-style, ANSI-safe) — re-exported here so block code has ONE
|
|
8
|
+
* width import site and the Review-Focus-2 invariant ("every rendered
|
|
9
|
+
* line's display width ≤ the pane") is enforced in one place:
|
|
10
|
+
* {@link truncate}.
|
|
11
|
+
*/
|
|
12
|
+
import type { Size } from "@sigrun/shared/blocks";
|
|
13
|
+
/** The container width classes (spec §12.1, TUI column). */
|
|
14
|
+
export type WidthClass = "compact" | "regular" | "wide";
|
|
15
|
+
/** The compact/regular/wide boundary: cells (compact < 80 ≤ regular < 140 ≤ wide). */
|
|
16
|
+
export declare function widthClassOf(cells: number): WidthClass;
|
|
17
|
+
/** Width tokens in cells (spec §5.2: the TUI maps s/m/l to 30/45/60). */
|
|
18
|
+
export declare const SIZE_CELLS: Readonly<Record<Size, number>>;
|
|
19
|
+
/**
|
|
20
|
+
* Clamp one rendered line to `width` cells with an ellipsis (Review
|
|
21
|
+
* Focus 2): ANSI-aware, wide-character-aware, never a mid-sequence cut.
|
|
22
|
+
* Empty lines pass through.
|
|
23
|
+
*/
|
|
24
|
+
export declare function truncate(line: string, width: number): string;
|
|
25
|
+
/** Pad a line to `width` cells by its DISPLAY width (for column joins). */
|
|
26
|
+
export declare function pad(line: string, width: number): string;
|
|
27
|
+
/**
|
|
28
|
+
* Fit a run of source lines into `width` cells: short runs stay one
|
|
29
|
+
* line (truncated); longer word-wraps fold at spaces, each folded line
|
|
30
|
+
* truncated. Used for plain-text values (markdown wraps itself).
|
|
31
|
+
*/
|
|
32
|
+
export declare function wrapPlain(text: string, width: number): string[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal width for block rendering (UI-blocks U2 Task 1): the width
|
|
3
|
+
* classes (spec §12.1), the `s`/`m`/`l` token scale (spec §5.2), and the
|
|
4
|
+
* clamp/pad helpers every block renderer funnels through.
|
|
5
|
+
*
|
|
6
|
+
* The display-width measure is pi-tui's `visibleWidth` (grapheme-aware,
|
|
7
|
+
* wcwidth-style, ANSI-safe) — re-exported here so block code has ONE
|
|
8
|
+
* width import site and the Review-Focus-2 invariant ("every rendered
|
|
9
|
+
* line's display width ≤ the pane") is enforced in one place:
|
|
10
|
+
* {@link truncate}.
|
|
11
|
+
*/
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
/** The compact/regular/wide boundary: cells (compact < 80 ≤ regular < 140 ≤ wide). */
|
|
14
|
+
export function widthClassOf(cells) {
|
|
15
|
+
if (cells < 80)
|
|
16
|
+
return "compact";
|
|
17
|
+
if (cells < 140)
|
|
18
|
+
return "regular";
|
|
19
|
+
return "wide";
|
|
20
|
+
}
|
|
21
|
+
/** Width tokens in cells (spec §5.2: the TUI maps s/m/l to 30/45/60). */
|
|
22
|
+
export const SIZE_CELLS = {
|
|
23
|
+
s: 30,
|
|
24
|
+
m: 45,
|
|
25
|
+
l: 60,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Clamp one rendered line to `width` cells with an ellipsis (Review
|
|
29
|
+
* Focus 2): ANSI-aware, wide-character-aware, never a mid-sequence cut.
|
|
30
|
+
* Empty lines pass through.
|
|
31
|
+
*/
|
|
32
|
+
export function truncate(line, width) {
|
|
33
|
+
if (line === "")
|
|
34
|
+
return "";
|
|
35
|
+
return truncateToWidth(line, width, "…");
|
|
36
|
+
}
|
|
37
|
+
/** Pad a line to `width` cells by its DISPLAY width (for column joins). */
|
|
38
|
+
export function pad(line, width) {
|
|
39
|
+
const rest = width - visibleWidth(line);
|
|
40
|
+
return rest > 0 ? line + " ".repeat(rest) : line;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Fit a run of source lines into `width` cells: short runs stay one
|
|
44
|
+
* line (truncated); longer word-wraps fold at spaces, each folded line
|
|
45
|
+
* truncated. Used for plain-text values (markdown wraps itself).
|
|
46
|
+
*/
|
|
47
|
+
export function wrapPlain(text, width) {
|
|
48
|
+
if (width <= 0)
|
|
49
|
+
return [];
|
|
50
|
+
const words = text.split(/\s+/).filter((w) => w !== "");
|
|
51
|
+
if (words.length === 0)
|
|
52
|
+
return [""];
|
|
53
|
+
const lines = [];
|
|
54
|
+
let current = "";
|
|
55
|
+
for (const word of words) {
|
|
56
|
+
if (current === "") {
|
|
57
|
+
current = word;
|
|
58
|
+
}
|
|
59
|
+
else if (visibleWidth(current) + 1 + visibleWidth(word) <= width) {
|
|
60
|
+
current += ` ${word}`;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
lines.push(current);
|
|
64
|
+
current = word;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (current !== "")
|
|
68
|
+
lines.push(current);
|
|
69
|
+
return lines.map((line) => truncate(line, width));
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal clipboard (Task C3, UI refresh): OSC 52 writes the system
|
|
3
|
+
* clipboard through the terminal emulator. Inside tmux the sequence must
|
|
4
|
+
* be DCS-wrapped with every ESC doubled, or tmux eats it.
|
|
5
|
+
*
|
|
6
|
+
* `copyToTerminal` writes the sequence to the terminal; the app flashes
|
|
7
|
+
* "Copied" in the status bar on top of this.
|
|
8
|
+
*/
|
|
9
|
+
/** The environment slice the tmux detection reads. */
|
|
10
|
+
export interface ClipboardEnv {
|
|
11
|
+
readonly TMUX?: string;
|
|
12
|
+
}
|
|
13
|
+
/** The OSC 52 sequence for `text` (base64 UTF-8), tmux-wrapped when needed. */
|
|
14
|
+
export declare function osc52(text: string, env?: ClipboardEnv): string;
|
|
15
|
+
/** The terminal surface the copy needs (pi-tui's `Terminal.write`). */
|
|
16
|
+
export interface ClipboardTerminal {
|
|
17
|
+
write(data: string): void;
|
|
18
|
+
}
|
|
19
|
+
/** Write `text` to the system clipboard via the terminal. */
|
|
20
|
+
export declare function copyToTerminal(terminal: ClipboardTerminal, text: string, env?: ClipboardEnv): void;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal clipboard (Task C3, UI refresh): OSC 52 writes the system
|
|
3
|
+
* clipboard through the terminal emulator. Inside tmux the sequence must
|
|
4
|
+
* be DCS-wrapped with every ESC doubled, or tmux eats it.
|
|
5
|
+
*
|
|
6
|
+
* `copyToTerminal` writes the sequence to the terminal; the app flashes
|
|
7
|
+
* "Copied" in the status bar on top of this.
|
|
8
|
+
*/
|
|
9
|
+
/** The OSC 52 sequence for `text` (base64 UTF-8), tmux-wrapped when needed. */
|
|
10
|
+
export function osc52(text, env = process.env) {
|
|
11
|
+
const seq = `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
|
|
12
|
+
if (env.TMUX === undefined || env.TMUX === "")
|
|
13
|
+
return seq;
|
|
14
|
+
return `\x1bPtmux;\x1b${seq.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
|
|
15
|
+
}
|
|
16
|
+
/** Write `text` to the system clipboard via the terminal. */
|
|
17
|
+
export function copyToTerminal(terminal, text, env = process.env) {
|
|
18
|
+
terminal.write(osc52(text, env));
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const VIM_NAMES: readonly string[];
|
|
2
|
+
/** True when the basename of the first whitespace-separated token is a vim binary. */
|
|
3
|
+
export declare function isVimFamily(command: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a vim-family editor: configured → VISUAL → EDITOR (each used only
|
|
6
|
+
* when vim-family), then nvim/vim/vi on PATH. Undefined when nothing qualifies.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveVimEditor(opts: {
|
|
9
|
+
configured?: string;
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
which(name: string): string | undefined;
|
|
12
|
+
}): string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Edit `text` in a vim-family editor via a temp file, suspending the TUI for
|
|
15
|
+
* the duration. The temp file is always removed; on failure the draft is
|
|
16
|
+
* untouched and stop/start remain balanced.
|
|
17
|
+
*/
|
|
18
|
+
export declare function editInVim(opts: {
|
|
19
|
+
text: string;
|
|
20
|
+
editor: string;
|
|
21
|
+
tui: {
|
|
22
|
+
stop(): void;
|
|
23
|
+
start(): void;
|
|
24
|
+
requestRender(force?: boolean): void;
|
|
25
|
+
};
|
|
26
|
+
spawnSync?: typeof import("node:child_process").spawnSync;
|
|
27
|
+
tmpDir?: string;
|
|
28
|
+
}): {
|
|
29
|
+
ok: true;
|
|
30
|
+
text: string;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawnSync as realSpawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
export const VIM_NAMES = ["vim", "nvim", "vi"];
|
|
7
|
+
/** True when the basename of the first whitespace-separated token is a vim binary. */
|
|
8
|
+
export function isVimFamily(command) {
|
|
9
|
+
const first = command.trim().split(/\s+/)[0];
|
|
10
|
+
if (!first)
|
|
11
|
+
return false;
|
|
12
|
+
return VIM_NAMES.includes(basename(first));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a vim-family editor: configured → VISUAL → EDITOR (each used only
|
|
16
|
+
* when vim-family), then nvim/vim/vi on PATH. Undefined when nothing qualifies.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveVimEditor(opts) {
|
|
19
|
+
for (const candidate of [opts.configured, opts.env.VISUAL, opts.env.EDITOR]) {
|
|
20
|
+
if (candidate && isVimFamily(candidate))
|
|
21
|
+
return candidate;
|
|
22
|
+
}
|
|
23
|
+
for (const name of ["nvim", "vim", "vi"]) {
|
|
24
|
+
const found = opts.which(name);
|
|
25
|
+
if (found)
|
|
26
|
+
return found;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Edit `text` in a vim-family editor via a temp file, suspending the TUI for
|
|
32
|
+
* the duration. The temp file is always removed; on failure the draft is
|
|
33
|
+
* untouched and stop/start remain balanced.
|
|
34
|
+
*/
|
|
35
|
+
export function editInVim(opts) {
|
|
36
|
+
const spawn = opts.spawnSync ?? realSpawnSync;
|
|
37
|
+
const file = join(opts.tmpDir ?? tmpdir(), `sigrun-prompt-${randomBytes(8).toString("hex")}.md`);
|
|
38
|
+
writeFileSync(file, opts.text, { encoding: "utf8", mode: 0o600 });
|
|
39
|
+
// The editor string splits on whitespace — quoted paths with spaces in
|
|
40
|
+
// the command or its arguments are NOT supported (a known limitation).
|
|
41
|
+
const [command, ...args] = opts.editor.trim().split(/\s+/);
|
|
42
|
+
try {
|
|
43
|
+
opts.tui.stop();
|
|
44
|
+
const result = spawn(command, [...args, file], { stdio: "inherit" });
|
|
45
|
+
if (result.error) {
|
|
46
|
+
return { ok: false, reason: `editor failed to launch: ${result.error.message}` };
|
|
47
|
+
}
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
return { ok: false, reason: `editor exited with status ${result.status}` };
|
|
50
|
+
}
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = readFileSync(file, "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { ok: false, reason: "edited file missing or unreadable" };
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, text };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
opts.tui.start();
|
|
62
|
+
opts.tui.requestRender(true);
|
|
63
|
+
try {
|
|
64
|
+
unlinkSync(file);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// already gone (editor deleted it) — nothing to clean up
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emacs keymap (Task C5, UI refresh): modeless, chord-driven. `C-x`
|
|
3
|
+
* ("\x18") arms a 1 000 ms chord: `C-x b` conversation picker, `C-x o`
|
|
4
|
+
* focus cycle, `C-x C-c` quit, `C-x k` a consumed no-op (a mismatched
|
|
5
|
+
* follow-up is the emacs undefined-key beep — consumed, never typed).
|
|
6
|
+
* `M-x` opens the command picker; `C-s` / `C-r` search forward / backward
|
|
7
|
+
* (the app's search overlay turns them into next / previous while open);
|
|
8
|
+
* `C-g` cancels a live run, closes the search/picker (it passes through
|
|
9
|
+
* to the open overlay), or focuses the composer. `M-p` / `M-n` jump
|
|
10
|
+
* between prompts; `M-w` copy, `M-f` fork, `M-e` edit are outside-
|
|
11
|
+
* composer actions (in the composer `M-f` is the editor's word-forward —
|
|
12
|
+
* all three pass). `C-n` / `C-p` move outside the composer; `C-o`
|
|
13
|
+
* answers an interaction, `C-l` redraws, `C-h` opens the key reference.
|
|
14
|
+
* Every {@link RESERVED_KEYS} chord passes in every focus — ctrl+b is
|
|
15
|
+
* NEVER bound (no `C-x C-b` either).
|
|
16
|
+
*/
|
|
17
|
+
import { type Keymap } from "./types.js";
|
|
18
|
+
export declare function createEmacsKeymap(): Keymap;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emacs keymap (Task C5, UI refresh): modeless, chord-driven. `C-x`
|
|
3
|
+
* ("\x18") arms a 1 000 ms chord: `C-x b` conversation picker, `C-x o`
|
|
4
|
+
* focus cycle, `C-x C-c` quit, `C-x k` a consumed no-op (a mismatched
|
|
5
|
+
* follow-up is the emacs undefined-key beep — consumed, never typed).
|
|
6
|
+
* `M-x` opens the command picker; `C-s` / `C-r` search forward / backward
|
|
7
|
+
* (the app's search overlay turns them into next / previous while open);
|
|
8
|
+
* `C-g` cancels a live run, closes the search/picker (it passes through
|
|
9
|
+
* to the open overlay), or focuses the composer. `M-p` / `M-n` jump
|
|
10
|
+
* between prompts; `M-w` copy, `M-f` fork, `M-e` edit are outside-
|
|
11
|
+
* composer actions (in the composer `M-f` is the editor's word-forward —
|
|
12
|
+
* all three pass). `C-n` / `C-p` move outside the composer; `C-o`
|
|
13
|
+
* answers an interaction, `C-l` redraws, `C-h` opens the key reference.
|
|
14
|
+
* Every {@link RESERVED_KEYS} chord passes in every focus — ctrl+b is
|
|
15
|
+
* NEVER bound (no `C-x C-b` either).
|
|
16
|
+
*/
|
|
17
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
18
|
+
import { RESERVED_KEYS } from "./types.js";
|
|
19
|
+
/** The C-x chord window. */
|
|
20
|
+
const CHORD_MS = 1_000;
|
|
21
|
+
const BINDINGS = [
|
|
22
|
+
{ keys: "C-x b", action: "conversation picker" },
|
|
23
|
+
{ keys: "C-x o", action: "cycle focus" },
|
|
24
|
+
{ keys: "C-x C-c", action: "quit" },
|
|
25
|
+
{ keys: "M-x", action: "command picker" },
|
|
26
|
+
{ keys: "C-s / C-r", action: "search forward / backward" },
|
|
27
|
+
{ keys: "C-g", action: "cancel run / close / focus composer" },
|
|
28
|
+
{ keys: "M-p / M-n", action: "previous / next prompt" },
|
|
29
|
+
{ keys: "M-w", action: "copy turn" },
|
|
30
|
+
{ keys: "M-f", action: "fork here" },
|
|
31
|
+
{ keys: "M-e", action: "edit here" },
|
|
32
|
+
{ keys: "C-n / C-p", action: "move down / up" },
|
|
33
|
+
{ keys: "enter", action: "open / fold" },
|
|
34
|
+
{ keys: "C-o", action: "answer interaction" },
|
|
35
|
+
{ keys: "C-l", action: "redraw" },
|
|
36
|
+
{ keys: "C-h", action: "key reference" },
|
|
37
|
+
{ keys: "M-m", action: "pick up (block surface)" },
|
|
38
|
+
{ keys: "enter", action: "drop (block surface)" },
|
|
39
|
+
{ keys: "C-g", action: "cancel move (block surface)" },
|
|
40
|
+
{ keys: "space", action: "select (block surface)" },
|
|
41
|
+
{ keys: ".", action: "action menu (block surface)" },
|
|
42
|
+
{ keys: "x", action: "check (block surface)" },
|
|
43
|
+
{ keys: "[ / ]", action: "previous / next range (calendar)" },
|
|
44
|
+
{ keys: "t", action: "today (calendar)" },
|
|
45
|
+
];
|
|
46
|
+
/** Match `data` against any reserved chord (they always pass). */
|
|
47
|
+
function isReserved(data) {
|
|
48
|
+
return RESERVED_KEYS.some((id) => matchesKey(data, id));
|
|
49
|
+
}
|
|
50
|
+
export function createEmacsKeymap() {
|
|
51
|
+
/** The armed C-x prefix timestamp (undefined = no chord pending). */
|
|
52
|
+
let chordAt;
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the C-x chord against `data`. Returns the chord's result,
|
|
55
|
+
* "consume" when `data` ARMS the prefix, or undefined to process
|
|
56
|
+
* `data` fresh (no prefix pending, or the window expired).
|
|
57
|
+
*/
|
|
58
|
+
const chord = (data) => {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (chordAt !== undefined) {
|
|
61
|
+
const armed = chordAt;
|
|
62
|
+
chordAt = undefined;
|
|
63
|
+
if (now - armed <= CHORD_MS) {
|
|
64
|
+
if (data === "b")
|
|
65
|
+
return { type: "picker", kind: "conversation" };
|
|
66
|
+
if (data === "o")
|
|
67
|
+
return { type: "focus-cycle", direction: 1 };
|
|
68
|
+
if (data === "\x03")
|
|
69
|
+
return { type: "quit" }; // C-x C-c
|
|
70
|
+
if (data === "k")
|
|
71
|
+
return "consume"; // C-x k: a no-op, consumed
|
|
72
|
+
return "consume"; // undefined chord: the emacs beep
|
|
73
|
+
}
|
|
74
|
+
// Expired: `data` is processed fresh below.
|
|
75
|
+
}
|
|
76
|
+
if (data === "\x18") {
|
|
77
|
+
chordAt = now;
|
|
78
|
+
return "consume";
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
};
|
|
82
|
+
/** Block-surface keys (spec §12.4; emacs picks up with M-m). C-g is
|
|
83
|
+
* the cancel key — it cancels a move on a surface BEFORE the global
|
|
84
|
+
* C-g (cancel-run / focus composer) below sees it. */
|
|
85
|
+
const surface = (data) => {
|
|
86
|
+
if (data === "\x1bm")
|
|
87
|
+
return { type: "blocks.pickUp" }; // M-m
|
|
88
|
+
if (matchesKey(data, Key.enter) || data === "\r")
|
|
89
|
+
return { type: "blocks.drop" };
|
|
90
|
+
if (data === "\x07")
|
|
91
|
+
return { type: "blocks.cancelMove" }; // C-g
|
|
92
|
+
if (data === " ")
|
|
93
|
+
return { type: "blocks.select" };
|
|
94
|
+
if (data === ".")
|
|
95
|
+
return { type: "blocks.actionMenu" };
|
|
96
|
+
if (data === "x")
|
|
97
|
+
return { type: "blocks.check" };
|
|
98
|
+
if (data === "[")
|
|
99
|
+
return { type: "blocks.rangePrev" };
|
|
100
|
+
if (data === "]")
|
|
101
|
+
return { type: "blocks.rangeNext" };
|
|
102
|
+
if (data === "t")
|
|
103
|
+
return { type: "blocks.today" };
|
|
104
|
+
return undefined;
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
name: "emacs",
|
|
108
|
+
modeLabel: () => undefined,
|
|
109
|
+
handle(data, ctx) {
|
|
110
|
+
// The composer's readline chords + the tmux prefix: NEVER bound.
|
|
111
|
+
// A reserved pass-through disarms a pending C-x — otherwise
|
|
112
|
+
// `C-x C-b b` would fire the picker with the user's "b".
|
|
113
|
+
if (isReserved(data)) {
|
|
114
|
+
chordAt = undefined;
|
|
115
|
+
return "pass";
|
|
116
|
+
}
|
|
117
|
+
// A modal overlay owns the keys; C-g passes too — the picker and
|
|
118
|
+
// the search overlay close themselves on it.
|
|
119
|
+
if (ctx.overlayOpen)
|
|
120
|
+
return "pass";
|
|
121
|
+
const chorded = chord(data);
|
|
122
|
+
if (chorded !== undefined)
|
|
123
|
+
return chorded;
|
|
124
|
+
// A block surface owns the §12.4 keys (its C-g is cancel-move,
|
|
125
|
+
// which must win over the global C-g cancel-run).
|
|
126
|
+
if (ctx.focus === "surface") {
|
|
127
|
+
const surfaceResult = surface(data);
|
|
128
|
+
if (surfaceResult !== undefined)
|
|
129
|
+
return surfaceResult;
|
|
130
|
+
}
|
|
131
|
+
if (data === "\x1bx")
|
|
132
|
+
return { type: "picker", kind: "command" };
|
|
133
|
+
if (data === "\x13")
|
|
134
|
+
return { type: "search", direction: "forward" };
|
|
135
|
+
if (data === "\x12")
|
|
136
|
+
return { type: "search", direction: "backward" };
|
|
137
|
+
if (data === "\x07") {
|
|
138
|
+
return ctx.busy
|
|
139
|
+
? { type: "cancel-run" }
|
|
140
|
+
: { type: "focus", target: "composer" };
|
|
141
|
+
}
|
|
142
|
+
if (data === "\x1bp")
|
|
143
|
+
return { type: "prompt-jump", delta: -1 };
|
|
144
|
+
if (data === "\x1bn")
|
|
145
|
+
return { type: "prompt-jump", delta: 1 };
|
|
146
|
+
if (data === "\x0f")
|
|
147
|
+
return { type: "answer-interaction" };
|
|
148
|
+
if (data === "\x0c")
|
|
149
|
+
return { type: "redraw" };
|
|
150
|
+
if (data === "\x08")
|
|
151
|
+
return { type: "help" };
|
|
152
|
+
if (ctx.focus === "composer")
|
|
153
|
+
return "pass";
|
|
154
|
+
// Outside the composer: the transcript/list actions.
|
|
155
|
+
if (data === "\x1bw")
|
|
156
|
+
return { type: "copy" };
|
|
157
|
+
if (data === "\x1bf")
|
|
158
|
+
return { type: "fork-here" };
|
|
159
|
+
if (data === "\x1be")
|
|
160
|
+
return { type: "edit-here" };
|
|
161
|
+
if (data === "\x0e")
|
|
162
|
+
return { type: "move", delta: 1 };
|
|
163
|
+
if (data === "\x10")
|
|
164
|
+
return { type: "move", delta: -1 };
|
|
165
|
+
if (matchesKey(data, Key.enter) || data === "\r")
|
|
166
|
+
return { type: "open" };
|
|
167
|
+
return "pass";
|
|
168
|
+
},
|
|
169
|
+
hints: () => ["C-x b chats", "M-x commands", "C-h keys"],
|
|
170
|
+
describe: () => BINDINGS,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standard keymap (Task C3, UI refresh): readline-friendly globals
|
|
3
|
+
* plus vi-flavoured navigation OUTSIDE the composer. The composer owns
|
|
4
|
+
* every printable key and every {@link RESERVED_KEYS} chord — nothing
|
|
5
|
+
* here shadows a line-editing key (ctrl+d/ctrl+u included), and ctrl+b
|
|
6
|
+
* is never bound anywhere.
|
|
7
|
+
*
|
|
8
|
+
* Global (every focus): ctrl+p conversation picker, ctrl+n new
|
|
9
|
+
* conversation, ctrl+t cycle thinking, ctrl+o answer interaction,
|
|
10
|
+
* ctrl+l redraw, ctrl+c quit, tab/shift+tab focus cycle, Esc (abort a
|
|
11
|
+
* live run, else focus the composer; passes through while the
|
|
12
|
+
* composer's autocomplete is open). Outside the composer: arrows move,
|
|
13
|
+
* PgUp/PgDn page, Home/End jump, Enter open, y copy, f fork, e edit,
|
|
14
|
+
* / search, n/N next match, ? help.
|
|
15
|
+
*/
|
|
16
|
+
import { type Keymap } from "./types.js";
|
|
17
|
+
export declare function createStandardKeymap(): Keymap;
|