pi-blackhole 0.4.2 → 0.4.3
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/README.md +15 -0
- package/dist/index.js +11660 -0
- package/dist/index.js.map +1 -0
- package/example-config.json +1 -1
- package/index.ts +37 -63
- package/package.json +21 -9
- package/src/commands/cleanup.ts +279 -240
- package/src/commands/memory.ts +236 -184
- package/src/commands/pi-vcc.ts +202 -152
- package/src/commands/vcc-recall.ts +126 -95
- package/src/core/brief.ts +167 -33
- package/src/core/build-sections.ts +8 -2
- package/src/core/config-env.ts +117 -0
- package/src/core/content.ts +31 -7
- package/src/core/drill-down.ts +41 -11
- package/src/core/filter-noise.ts +9 -3
- package/src/core/format-recall.ts +15 -6
- package/src/core/format.ts +14 -4
- package/src/core/lineage.ts +9 -3
- package/src/core/load-messages.ts +24 -5
- package/src/core/normalize.ts +38 -14
- package/src/core/recall-scope.ts +11 -3
- package/src/core/render-entries.ts +22 -6
- package/src/core/sanitize.ts +5 -1
- package/src/core/search-entries.ts +111 -19
- package/src/core/settings.ts +1 -3
- package/src/core/summarize.ts +42 -21
- package/src/core/unified-config.ts +549 -411
- package/src/extract/commits.ts +4 -2
- package/src/extract/files.ts +10 -5
- package/src/extract/goals.ts +7 -2
- package/src/hooks/before-compact.ts +210 -88
- package/src/om/agents/dropper/agent.ts +380 -265
- package/src/om/agents/dropper/coverage.ts +102 -82
- package/src/om/agents/observer/agent.ts +242 -206
- package/src/om/agents/reflector/agent.ts +212 -153
- package/src/om/cleanup.ts +239 -218
- package/src/om/clipboard.ts +59 -51
- package/src/om/compaction-trigger.ts +448 -333
- package/src/om/config.ts +13 -6
- package/src/om/configure-overlay.ts +518 -355
- package/src/om/consolidation.ts +1460 -953
- package/src/om/cooldown.ts +75 -65
- package/src/om/debug-log.ts +86 -68
- package/src/om/ids.ts +1 -1
- package/src/om/ledger/fold.ts +89 -78
- package/src/om/ledger/progress.ts +181 -153
- package/src/om/ledger/projection.ts +248 -185
- package/src/om/ledger/recall.ts +247 -196
- package/src/om/ledger/render-summary.ts +79 -50
- package/src/om/ledger/types.ts +146 -117
- package/src/om/model-budget.ts +23 -13
- package/src/om/pending.ts +243 -179
- package/src/om/provider-stream.ts +52 -7
- package/src/om/retryable-error.ts +12 -16
- package/src/om/reverse-recall.ts +97 -91
- package/src/om/runtime.ts +474 -375
- package/src/om/serialize.ts +190 -166
- package/src/om/status-overlay.ts +246 -195
- package/src/om/tokens.ts +28 -21
- package/src/pi-base/blackhole-settings.ts +437 -0
- package/src/pi-base/config-manager.ts +440 -0
- package/src/pi-base/config.ts +469 -0
- package/src/pi-base/env.ts +43 -0
- package/src/pi-base/paths.ts +47 -0
- package/src/pi-base/settings/body.ts +1648 -0
- package/src/pi-base/settings/fields/action.ts +43 -0
- package/src/pi-base/settings/fields/boolean.ts +47 -0
- package/src/pi-base/settings/fields/custom.ts +72 -0
- package/src/pi-base/settings/fields/enum.ts +310 -0
- package/src/pi-base/settings/fields/index.ts +46 -0
- package/src/pi-base/settings/fields/model.ts +452 -0
- package/src/pi-base/settings/fields/string.ts +527 -0
- package/src/pi-base/settings/fields/text.ts +115 -0
- package/src/pi-base/settings/frame.ts +197 -0
- package/src/pi-base/settings/index.ts +77 -0
- package/src/pi-base/settings/inline-edit.ts +313 -0
- package/src/pi-base/settings/modal.ts +152 -0
- package/src/pi-base/settings/types.ts +500 -0
- package/src/pi-base/settings/validate-field.ts +113 -0
- package/src/pi-base/shell.ts +117 -0
- package/src/pi-base/types.ts +6 -0
- package/src/pi-base/ui.ts +32 -0
- package/src/tools/recall.ts +347 -225
- package/src/types.ts +20 -3
- package/tsup.config.ts +23 -0
- package/vitest.config.ts +15 -15
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rounded-light frame helpers for the settings modal — and for any
|
|
3
|
+
* future overlay in this package that wants the same look.
|
|
4
|
+
*
|
|
5
|
+
* Visual:
|
|
6
|
+
*
|
|
7
|
+
* ╭── Title ──────────────────────╮
|
|
8
|
+
* │ │
|
|
9
|
+
* │ Muted off │
|
|
10
|
+
* │ Voice Umbriel │
|
|
11
|
+
* │ │
|
|
12
|
+
* ╰────────────────────────────────╯
|
|
13
|
+
*
|
|
14
|
+
* The frame draws:
|
|
15
|
+
* - A single-line top border with an inline title pill (`╭── Title ──╮`).
|
|
16
|
+
* Title text is coloured via the host theme's `accent` foreground.
|
|
17
|
+
* - A bottom border closing with `╰─╯`.
|
|
18
|
+
* - Vertical sides as `│` separators.
|
|
19
|
+
* - `paddingY` blank rows above and below the body, plus `paddingX`
|
|
20
|
+
* spaces left and right of every body row.
|
|
21
|
+
* - Optional row-count cap (`fixedInnerRows`) so a too-tall body is
|
|
22
|
+
* truncated with a `↓ N more line(s)` indicator instead of overflowing
|
|
23
|
+
* the popup.
|
|
24
|
+
*
|
|
25
|
+
* All helpers are width-aware: every output line is right-truncated to
|
|
26
|
+
* `width` columns using pi-tui's `truncateToWidth` so they never escape
|
|
27
|
+
* the overlay rectangle.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import {
|
|
32
|
+
truncateToWidth,
|
|
33
|
+
visibleWidth,
|
|
34
|
+
wrapTextWithAnsi,
|
|
35
|
+
} from "@earendil-works/pi-tui";
|
|
36
|
+
|
|
37
|
+
/** Default horizontal padding inside the frame, in columns. */
|
|
38
|
+
export const DEFAULT_PADDING_X = 2;
|
|
39
|
+
/** Default vertical padding inside the frame, in rows. */
|
|
40
|
+
export const DEFAULT_PADDING_Y = 1;
|
|
41
|
+
/** Total vertical rows consumed by the frame chrome (top + bottom + 2× padY). */
|
|
42
|
+
export const FRAME_VERTICAL_CHROME = 2 + DEFAULT_PADDING_Y * 2;
|
|
43
|
+
|
|
44
|
+
export interface FrameOptions {
|
|
45
|
+
/** Inline title in the top border. Truncated with `…` when too wide. */
|
|
46
|
+
title?: string;
|
|
47
|
+
/** Inner padding columns (left and right). Defaults to {@link DEFAULT_PADDING_X}. */
|
|
48
|
+
paddingX?: number;
|
|
49
|
+
/** Inner padding rows (top and bottom). Defaults to {@link DEFAULT_PADDING_Y}. */
|
|
50
|
+
paddingY?: number;
|
|
51
|
+
/**
|
|
52
|
+
* If set, the body is forced to exactly this many inner rows. Excess
|
|
53
|
+
* lines are replaced with a `↓ N more` indicator at the bottom; missing
|
|
54
|
+
* lines are padded with blank rows so the overlay never visually shrinks
|
|
55
|
+
* mid-session.
|
|
56
|
+
*/
|
|
57
|
+
fixedInnerRows?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Width available to body content, given a frame's outer `width`. */
|
|
61
|
+
export function frameContentWidth(
|
|
62
|
+
width: number,
|
|
63
|
+
paddingX: number = DEFAULT_PADDING_X,
|
|
64
|
+
): number {
|
|
65
|
+
return Math.max(1, width - 2 - paddingX * 2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Right-pad a (possibly ANSI-coloured) string to exactly `width` columns. */
|
|
69
|
+
export function pad(text: string, width: number): string {
|
|
70
|
+
const truncated = truncateToWidth(text, width, "");
|
|
71
|
+
return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Wrap `line` to fit `width` columns. Hard newlines split the line first;
|
|
76
|
+
* each segment is then word-wrapped (ANSI-aware via pi-tui's helper) and
|
|
77
|
+
* finally truncated as a defence-in-depth against pathological cases.
|
|
78
|
+
*/
|
|
79
|
+
export function wrapLine(line: string, width: number): string[] {
|
|
80
|
+
const safeWidth = Math.max(1, width);
|
|
81
|
+
const normalized = String(line ?? "").replace(/\t/g, " ");
|
|
82
|
+
const wrapped = normalized.split(/\r?\n/).flatMap((part) => {
|
|
83
|
+
const rows = wrapTextWithAnsi(part, safeWidth);
|
|
84
|
+
return rows.length > 0 ? rows : [""];
|
|
85
|
+
});
|
|
86
|
+
return wrapped.map((part) => truncateToWidth(part, safeWidth, ""));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Render a single horizontal divider line, themed in `dim`. */
|
|
90
|
+
export function divider(width: number, theme: Theme): string {
|
|
91
|
+
return theme.fg("dim", "─".repeat(Math.max(1, width)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One key-hint pair as rendered in the modal's footer. */
|
|
95
|
+
export interface KeyHint {
|
|
96
|
+
key: string;
|
|
97
|
+
label: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Format a list of key hints as a single line: each `key` rendered in
|
|
102
|
+
* the host theme's `accent` colour and each `label` in `dim`, joined
|
|
103
|
+
* by a dim middle-dot (`·`). Used by both the main settings modal
|
|
104
|
+
* footer and any submenu that wants matching highlighting.
|
|
105
|
+
*
|
|
106
|
+
* Returns the raw composed string — caller is responsible for
|
|
107
|
+
* truncating / wrapping if needed.
|
|
108
|
+
*/
|
|
109
|
+
export function formatHintLine(hints: KeyHint[], theme: Theme): string {
|
|
110
|
+
return hints
|
|
111
|
+
.map((h) => `${theme.fg("accent", h.key)} ${theme.fg("dim", h.label)}`)
|
|
112
|
+
.join(theme.fg("dim", " · "));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Wrap `lines` in a rounded-light frame. The top border embeds an
|
|
117
|
+
* optional title pill rendered in the host theme's `accent` colour; the
|
|
118
|
+
* border itself is themed in `borderAccent` so it stands out from chat
|
|
119
|
+
* but blends with the user's chosen palette.
|
|
120
|
+
*
|
|
121
|
+
* The output is exactly the right shape to hand to `ctx.ui.custom`'s
|
|
122
|
+
* overlay component — every line is exactly `width` columns wide.
|
|
123
|
+
*/
|
|
124
|
+
export function frame(
|
|
125
|
+
lines: string[],
|
|
126
|
+
width: number,
|
|
127
|
+
theme: Theme,
|
|
128
|
+
options: FrameOptions = {},
|
|
129
|
+
): string[] {
|
|
130
|
+
const paddingX = options.paddingX ?? DEFAULT_PADDING_X;
|
|
131
|
+
const paddingY = options.paddingY ?? DEFAULT_PADDING_Y;
|
|
132
|
+
const inner = Math.max(1, width - 2);
|
|
133
|
+
const contentWidth = frameContentWidth(width, paddingX);
|
|
134
|
+
const border = (s: string) => theme.fg("borderAccent", s);
|
|
135
|
+
|
|
136
|
+
let body = lines;
|
|
137
|
+
if (
|
|
138
|
+
options.fixedInnerRows !== undefined &&
|
|
139
|
+
body.length > options.fixedInnerRows
|
|
140
|
+
) {
|
|
141
|
+
const hidden = body.length - options.fixedInnerRows + 1;
|
|
142
|
+
body = [
|
|
143
|
+
...body.slice(0, Math.max(0, options.fixedInnerRows - 1)),
|
|
144
|
+
theme.fg("dim", `↓ ${hidden} more line(s)`),
|
|
145
|
+
].slice(0, options.fixedInnerRows);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const blank = `${border("│")}${" ".repeat(inner)}${border("│")}`;
|
|
149
|
+
const top = (): string => {
|
|
150
|
+
if (!options.title)
|
|
151
|
+
return `${border("╭")}${border("─".repeat(inner))}${border("╮")}`;
|
|
152
|
+
// Title pill: `── <title> ──`, truncated to fit; surrounded by border
|
|
153
|
+
// dashes on both sides so the pill always reaches the corners.
|
|
154
|
+
const titlePlain = ` ${truncateToWidth(options.title, Math.max(1, inner - 4), "…")} `;
|
|
155
|
+
const titleVisible = visibleWidth(titlePlain);
|
|
156
|
+
const leftDash = 2;
|
|
157
|
+
const rightDash = Math.max(1, inner - leftDash - titleVisible);
|
|
158
|
+
return (
|
|
159
|
+
`${border("╭")}${border("─".repeat(leftDash))}` +
|
|
160
|
+
`${theme.fg("accent", theme.bold(titlePlain))}` +
|
|
161
|
+
`${border("─".repeat(rightDash))}${border("╮")}`
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const out: string[] = [top()];
|
|
166
|
+
for (let i = 0; i < paddingY; i += 1) out.push(blank);
|
|
167
|
+
for (const line of body) {
|
|
168
|
+
out.push(
|
|
169
|
+
`${border("│")}${" ".repeat(paddingX)}${pad(line, contentWidth)}${" ".repeat(paddingX)}${border("│")}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
for (let i = 0; i < paddingY; i += 1) out.push(blank);
|
|
173
|
+
out.push(`${border("╰")}${border("─".repeat(inner))}${border("╯")}`);
|
|
174
|
+
return out.map((line) => truncateToWidth(line, width, ""));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Compute a sensible inner-row count given the terminal height and a
|
|
179
|
+
* preferred maximum. Mirrors the `responsiveInnerRows` math from
|
|
180
|
+
* `extension-manager.ts` so popups shrink gracefully on short terminals
|
|
181
|
+
* instead of clipping past the bottom edge.
|
|
182
|
+
*/
|
|
183
|
+
export function responsiveInnerRows(
|
|
184
|
+
terminalRows: number,
|
|
185
|
+
preferred: number,
|
|
186
|
+
minimum = 12,
|
|
187
|
+
ratio = 0.85,
|
|
188
|
+
): number {
|
|
189
|
+
const available = Math.max(
|
|
190
|
+
minimum + FRAME_VERTICAL_CHROME,
|
|
191
|
+
Math.floor(Math.max(1, terminalRows) * ratio),
|
|
192
|
+
);
|
|
193
|
+
return Math.max(
|
|
194
|
+
minimum,
|
|
195
|
+
Math.min(preferred, available - FRAME_VERTICAL_CHROME),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @k0valik/pi-base/settings — public entry point for the settings
|
|
3
|
+
* modal feature (originally vendored from wierdbytes/pi-common/settings;
|
|
4
|
+
* since heavily reworked — buffered save mode, scope-aware values,
|
|
5
|
+
* validation, fuzzy filtering).
|
|
6
|
+
*
|
|
7
|
+
* High-level: `openSettingsModal(ctx, opts)` opens a centered popup,
|
|
8
|
+
* persists changes via `opts.onChange`, and resolves on close.
|
|
9
|
+
*
|
|
10
|
+
* Mid-level: `createSettingsModal(ctx, opts)` returns a `ctx.ui.custom`
|
|
11
|
+
* factory for callers that manage their own overlay lifecycle.
|
|
12
|
+
*
|
|
13
|
+
* Low-level: `createSettingsModalBody`, `frame`, `inline-edit` helpers,
|
|
14
|
+
* built-in `RENDERERS` map, and the full `Field` discriminated union
|
|
15
|
+
* are all exported for callers building bespoke layouts on top of the
|
|
16
|
+
* same primitives.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { createSettingsModal, openSettingsModal } from "./modal";
|
|
20
|
+
|
|
21
|
+
export { createSettingsModalBody } from "./body";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
divider,
|
|
25
|
+
formatHintLine,
|
|
26
|
+
frame,
|
|
27
|
+
frameContentWidth,
|
|
28
|
+
pad,
|
|
29
|
+
responsiveInnerRows,
|
|
30
|
+
wrapLine,
|
|
31
|
+
type FrameOptions,
|
|
32
|
+
type KeyHint,
|
|
33
|
+
} from "./frame";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
clampInlineCursor,
|
|
37
|
+
deleteWordBackward,
|
|
38
|
+
handleInlineEditInput,
|
|
39
|
+
insertInlineText,
|
|
40
|
+
renderInlineEditValue,
|
|
41
|
+
type InlineEditState,
|
|
42
|
+
} from "./inline-edit";
|
|
43
|
+
|
|
44
|
+
export { RENDERERS } from "./fields/index";
|
|
45
|
+
|
|
46
|
+
export { validateFieldValue } from "./validate-field";
|
|
47
|
+
|
|
48
|
+
export type {
|
|
49
|
+
ActionField,
|
|
50
|
+
BooleanField,
|
|
51
|
+
CustomField,
|
|
52
|
+
CustomFieldRenderArgs,
|
|
53
|
+
CustomFieldSubmenuArgs,
|
|
54
|
+
EnumField,
|
|
55
|
+
Field,
|
|
56
|
+
FieldBase,
|
|
57
|
+
FieldKeyHint,
|
|
58
|
+
FieldKeyResult,
|
|
59
|
+
FieldRenderContext,
|
|
60
|
+
FieldRenderer,
|
|
61
|
+
FieldRow,
|
|
62
|
+
ModelField,
|
|
63
|
+
ModelOption,
|
|
64
|
+
ModelValue,
|
|
65
|
+
NumberField,
|
|
66
|
+
PathField,
|
|
67
|
+
SecretField,
|
|
68
|
+
SettingsModalFactory,
|
|
69
|
+
SettingsModalOptions,
|
|
70
|
+
SettingsTheme,
|
|
71
|
+
StringField,
|
|
72
|
+
SubmenuFactory,
|
|
73
|
+
Tab,
|
|
74
|
+
TextField,
|
|
75
|
+
ValueOfField,
|
|
76
|
+
VisibilityContext,
|
|
77
|
+
} from "./types";
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline string-editor state machine for single-line value editing inside
|
|
3
|
+
* settings rows.
|
|
4
|
+
*
|
|
5
|
+
* Ported from the helpers in vstack's `extension-manager.ts`. This file is
|
|
6
|
+
* **pure logic** — no terminal access, no theme, no rendering — so it can
|
|
7
|
+
* be exercised in `bun test` / `vitest` without a TUI.
|
|
8
|
+
*
|
|
9
|
+
* Key shape: a tiny `{ buffer, cursor }` struct mutated in place by the
|
|
10
|
+
* helper functions. `cursor` is a **code-unit** index into `buffer` (so
|
|
11
|
+
* `buffer.slice(0, cursor)` is always safe), but every cursor-moving
|
|
12
|
+
* helper internally walks **code points** so multi-unit characters
|
|
13
|
+
* (CJK, emoji, regional indicators) move atomically — pressing `←` once
|
|
14
|
+
* over a 🌍 jumps two code units, not one.
|
|
15
|
+
*
|
|
16
|
+
* Word-jump uses the same three-class scheme as most readline-derived
|
|
17
|
+
* editors:
|
|
18
|
+
* - `space`: `\s` (whitespace);
|
|
19
|
+
* - `word`: `[A-Za-z0-9_]` (identifier-ish);
|
|
20
|
+
* - `punct`: everything else.
|
|
21
|
+
*
|
|
22
|
+
* Tabs / hard-newlines are intentionally not special-cased — settings
|
|
23
|
+
* values are single-line strings, and any `\n` arriving from terminal
|
|
24
|
+
* input would be the wrong kind of input to this editor anyway.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
|
|
28
|
+
|
|
29
|
+
/** Shared yank buffer for storing killed text. */
|
|
30
|
+
let yankBuffer = "";
|
|
31
|
+
|
|
32
|
+
/** Retrieve the current content of the yank buffer. */
|
|
33
|
+
export function getInlineYankBuffer(): string {
|
|
34
|
+
return yankBuffer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Set the content of the yank buffer. */
|
|
38
|
+
export function setInlineYankBuffer(val: string): void {
|
|
39
|
+
yankBuffer = val;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mutable cursor state. `cursor` is a code-unit offset into `buffer`. */
|
|
43
|
+
export interface InlineEditState {
|
|
44
|
+
buffer: string;
|
|
45
|
+
cursor: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface InlineEditChar {
|
|
49
|
+
ch: string;
|
|
50
|
+
/** Inclusive code-unit start of this char in the source buffer. */
|
|
51
|
+
start: number;
|
|
52
|
+
/** Exclusive code-unit end (==start of the next char). */
|
|
53
|
+
end: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Decompose `text` into its sequence of code-point chars with their
|
|
57
|
+
* code-unit ranges. Used to keep the cursor on a code-point boundary. */
|
|
58
|
+
export function inlineEditChars(text: string): InlineEditChar[] {
|
|
59
|
+
const out: InlineEditChar[] = [];
|
|
60
|
+
let offset = 0;
|
|
61
|
+
for (const ch of text) {
|
|
62
|
+
const start = offset;
|
|
63
|
+
offset += ch.length;
|
|
64
|
+
out.push({ ch, start, end: offset });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Force `cursor` back into `[0, buffer.length]` after a buffer mutation. */
|
|
70
|
+
export function clampInlineCursor(editing: InlineEditState): void {
|
|
71
|
+
editing.cursor = Math.max(0, Math.min(editing.cursor, editing.buffer.length));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert a code-unit offset into the index of the char immediately to
|
|
76
|
+
* the right of the cursor. A cursor sitting on a char boundary returns
|
|
77
|
+
* the index of the char it is just before. A cursor past the end returns
|
|
78
|
+
* `chars.length`.
|
|
79
|
+
*/
|
|
80
|
+
export function codeUnitToCharIndex(
|
|
81
|
+
chars: InlineEditChar[],
|
|
82
|
+
cursor: number,
|
|
83
|
+
): number {
|
|
84
|
+
let index = 0;
|
|
85
|
+
while (index < chars.length && chars[index]!.end <= cursor) index += 1;
|
|
86
|
+
return index;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Inverse of `codeUnitToCharIndex`. */
|
|
90
|
+
export function charIndexToCodeUnit(
|
|
91
|
+
chars: InlineEditChar[],
|
|
92
|
+
index: number,
|
|
93
|
+
textLength: number,
|
|
94
|
+
): number {
|
|
95
|
+
if (index <= 0) return 0;
|
|
96
|
+
if (index >= chars.length) return textLength;
|
|
97
|
+
return chars[index]!.start;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Three-class word/word-boundary classifier used by word-jump helpers. */
|
|
101
|
+
export function inlineCharKind(ch: string): "space" | "word" | "punct" {
|
|
102
|
+
if (/\s/u.test(ch)) return "space";
|
|
103
|
+
if (/[A-Za-z0-9_]/.test(ch)) return "word";
|
|
104
|
+
return "punct";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Move the cursor by `delta` code-points (negative = left). */
|
|
108
|
+
export function moveInlineCursorByChars(
|
|
109
|
+
editing: InlineEditState,
|
|
110
|
+
delta: number,
|
|
111
|
+
): void {
|
|
112
|
+
const chars = inlineEditChars(editing.buffer);
|
|
113
|
+
const index = codeUnitToCharIndex(chars, editing.cursor);
|
|
114
|
+
editing.cursor = charIndexToCodeUnit(
|
|
115
|
+
chars,
|
|
116
|
+
index + delta,
|
|
117
|
+
editing.buffer.length,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Skip whitespace then a contiguous run of the same char-kind to the left. */
|
|
122
|
+
export function moveInlineCursorWordLeft(editing: InlineEditState): void {
|
|
123
|
+
const chars = inlineEditChars(editing.buffer);
|
|
124
|
+
let index = codeUnitToCharIndex(chars, editing.cursor);
|
|
125
|
+
while (index > 0 && inlineCharKind(chars[index - 1]!.ch) === "space")
|
|
126
|
+
index -= 1;
|
|
127
|
+
if (index <= 0) {
|
|
128
|
+
editing.cursor = 0;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const kind = inlineCharKind(chars[index - 1]!.ch);
|
|
132
|
+
while (index > 0 && inlineCharKind(chars[index - 1]!.ch) === kind) index -= 1;
|
|
133
|
+
editing.cursor = charIndexToCodeUnit(chars, index, editing.buffer.length);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Skip whitespace then a contiguous run of the same char-kind to the right. */
|
|
137
|
+
export function moveInlineCursorWordRight(editing: InlineEditState): void {
|
|
138
|
+
const chars = inlineEditChars(editing.buffer);
|
|
139
|
+
let index = codeUnitToCharIndex(chars, editing.cursor);
|
|
140
|
+
while (index < chars.length && inlineCharKind(chars[index]!.ch) === "space")
|
|
141
|
+
index += 1;
|
|
142
|
+
if (index >= chars.length) {
|
|
143
|
+
editing.cursor = editing.buffer.length;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const kind = inlineCharKind(chars[index]!.ch);
|
|
147
|
+
while (index < chars.length && inlineCharKind(chars[index]!.ch) === kind)
|
|
148
|
+
index += 1;
|
|
149
|
+
editing.cursor = charIndexToCodeUnit(chars, index, editing.buffer.length);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Insert `text` at the cursor and advance past it. */
|
|
153
|
+
export function insertInlineText(editing: InlineEditState, text: string): void {
|
|
154
|
+
clampInlineCursor(editing);
|
|
155
|
+
editing.buffer = `${editing.buffer.slice(0, editing.cursor)}${text}${editing.buffer.slice(editing.cursor)}`;
|
|
156
|
+
editing.cursor += text.length;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Delete the half-open `[start, end)` code-unit range; cursor lands at `start`. */
|
|
160
|
+
export function deleteInlineRange(
|
|
161
|
+
editing: InlineEditState,
|
|
162
|
+
start: number,
|
|
163
|
+
end: number,
|
|
164
|
+
): void {
|
|
165
|
+
const safeStart = Math.max(0, Math.min(start, editing.buffer.length));
|
|
166
|
+
const safeEnd = Math.max(safeStart, Math.min(end, editing.buffer.length));
|
|
167
|
+
editing.buffer = `${editing.buffer.slice(0, safeStart)}${editing.buffer.slice(safeEnd)}`;
|
|
168
|
+
editing.cursor = safeStart;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Heuristic: is `data` a single, plain printable input character? */
|
|
172
|
+
export function isPlainSearchInput(data: string): boolean {
|
|
173
|
+
// Kitty terminals report printable characters as CSI-u sequences
|
|
174
|
+
// (e.g. "5" arrives as \x1b[53u). Decode them so typing works there.
|
|
175
|
+
const decoded = decodeKittyPrintable(data) ?? data;
|
|
176
|
+
return decoded.length === 1 && decoded >= " " && decoded !== "\x7f";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Dispatch a raw terminal input string to the editor. Returns true when
|
|
181
|
+
* the input was consumed (caller should re-render); false when it didn't
|
|
182
|
+
* match any inline-edit shortcut (caller may handle it as a hot-key).
|
|
183
|
+
*/
|
|
184
|
+
export function handleInlineEditInput(
|
|
185
|
+
editing: InlineEditState,
|
|
186
|
+
data: string,
|
|
187
|
+
): boolean {
|
|
188
|
+
clampInlineCursor(editing);
|
|
189
|
+
if (matchesKey(data, "left") || matchesKey(data, "ctrl+b")) {
|
|
190
|
+
moveInlineCursorByChars(editing, -1);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
if (matchesKey(data, "right") || matchesKey(data, "ctrl+f")) {
|
|
194
|
+
moveInlineCursorByChars(editing, 1);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
matchesKey(data, "alt+left") ||
|
|
199
|
+
matchesKey(data, "ctrl+left") ||
|
|
200
|
+
matchesKey(data, "alt+b")
|
|
201
|
+
) {
|
|
202
|
+
moveInlineCursorWordLeft(editing);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (
|
|
206
|
+
matchesKey(data, "alt+right") ||
|
|
207
|
+
matchesKey(data, "ctrl+right") ||
|
|
208
|
+
matchesKey(data, "alt+f")
|
|
209
|
+
) {
|
|
210
|
+
moveInlineCursorWordRight(editing);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
|
|
214
|
+
editing.cursor = 0;
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
|
|
218
|
+
editing.cursor = editing.buffer.length;
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (matchesKey(data, "backspace") || matchesKey(data, "ctrl+h")) {
|
|
222
|
+
const before = editing.cursor;
|
|
223
|
+
moveInlineCursorByChars(editing, -1);
|
|
224
|
+
deleteInlineRange(editing, editing.cursor, before);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
if (matchesKey(data, "delete") || matchesKey(data, "ctrl+d")) {
|
|
228
|
+
const start = editing.cursor;
|
|
229
|
+
moveInlineCursorByChars(editing, 1);
|
|
230
|
+
deleteInlineRange(editing, start, editing.cursor);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
234
|
+
editing.buffer = "";
|
|
235
|
+
editing.cursor = 0;
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
if (matchesKey(data, "ctrl+w")) {
|
|
239
|
+
const current = editing.cursor;
|
|
240
|
+
moveInlineCursorWordLeft(editing);
|
|
241
|
+
const target = editing.cursor;
|
|
242
|
+
const killedText = editing.buffer.slice(target, current);
|
|
243
|
+
if (killedText) {
|
|
244
|
+
yankBuffer = killedText;
|
|
245
|
+
}
|
|
246
|
+
deleteInlineRange(editing, target, current);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (matchesKey(data, "alt+d")) {
|
|
250
|
+
const current = editing.cursor;
|
|
251
|
+
moveInlineCursorWordRight(editing);
|
|
252
|
+
const target = editing.cursor;
|
|
253
|
+
const killedText = editing.buffer.slice(current, target);
|
|
254
|
+
if (killedText) {
|
|
255
|
+
yankBuffer = killedText;
|
|
256
|
+
}
|
|
257
|
+
deleteInlineRange(editing, current, target);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (matchesKey(data, "ctrl+k")) {
|
|
261
|
+
const killedText = editing.buffer.slice(editing.cursor);
|
|
262
|
+
if (killedText) {
|
|
263
|
+
yankBuffer = killedText;
|
|
264
|
+
}
|
|
265
|
+
deleteInlineRange(editing, editing.cursor, editing.buffer.length);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
if (matchesKey(data, "ctrl+y")) {
|
|
269
|
+
if (yankBuffer) {
|
|
270
|
+
insertInlineText(editing, yankBuffer);
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (isPlainSearchInput(data)) {
|
|
275
|
+
// Kitty terminals deliver printable chars as CSI-u sequences —
|
|
276
|
+
// decode them so the decoded char (not the raw escape bytes) is
|
|
277
|
+
// inserted into the buffer.
|
|
278
|
+
const decoded = decodeKittyPrintable(data) ?? data;
|
|
279
|
+
insertInlineText(editing, decoded);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Render the buffer with a `█` block at the cursor position. Suitable
|
|
287
|
+
* for embedding in a one-line settings row.
|
|
288
|
+
*/
|
|
289
|
+
export function renderInlineEditValue(editing: InlineEditState): string {
|
|
290
|
+
clampInlineCursor(editing);
|
|
291
|
+
return `${editing.buffer.slice(0, editing.cursor)}█${editing.buffer.slice(editing.cursor)}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Word-deletion helper to delete a word backward from the end of a string.
|
|
296
|
+
*/
|
|
297
|
+
export function deleteWordBackward(text: string): string {
|
|
298
|
+
let i = text.length;
|
|
299
|
+
while (i > 0 && /\s/.test(text[i - 1]!)) {
|
|
300
|
+
i--;
|
|
301
|
+
}
|
|
302
|
+
if (i === 0) return "";
|
|
303
|
+
const charKind = (ch: string) => {
|
|
304
|
+
if (/\s/.test(ch)) return "space";
|
|
305
|
+
if (/[A-Za-z0-9_]/.test(ch)) return "word";
|
|
306
|
+
return "punct";
|
|
307
|
+
};
|
|
308
|
+
const kind = charKind(text[i - 1]!);
|
|
309
|
+
while (i > 0 && charKind(text[i - 1]!) === kind) {
|
|
310
|
+
i--;
|
|
311
|
+
}
|
|
312
|
+
return text.slice(0, i);
|
|
313
|
+
}
|