pi-blackhole 0.4.1 → 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.
Files changed (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -410
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. 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
+ }