@pugi/cli 0.1.0-alpha.10
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 +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +71 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FZF-style history search - Sprint α6.14.
|
|
3
|
+
*
|
|
4
|
+
* Powers the Ctrl+R (reverse) / Ctrl+S (forward) interactive search
|
|
5
|
+
* mode in the REPL input box. Hand-rolled scorer (no dep) tuned for
|
|
6
|
+
* short query strings (operator typing a few chars) over up to
|
|
7
|
+
* MAX_HISTORY_ENTRIES candidates.
|
|
8
|
+
*
|
|
9
|
+
* Scoring model (mirrors fzf v2 mid-priorities, simplified):
|
|
10
|
+
*
|
|
11
|
+
* - Substring match required. Non-matching candidates score 0.
|
|
12
|
+
* - Prefix match: +50
|
|
13
|
+
* - Word-boundary start (' ', '/', '-', '_'): +20
|
|
14
|
+
* - Camel boundary (`abcD` → 'D'): +10
|
|
15
|
+
* - Contiguous run bonus: +5 per consecutive char after the first
|
|
16
|
+
* - Distance penalty: -1 per gap char between query characters
|
|
17
|
+
* - Recency bonus: +0.5 × (recencyRank / total) so newer entries
|
|
18
|
+
* break ties in the operator's favour
|
|
19
|
+
*
|
|
20
|
+
* The result is a list of matches ordered by descending score with
|
|
21
|
+
* the matched character positions preserved so the UI can highlight
|
|
22
|
+
* them. `cycle` steps the focused match index forward (Ctrl+R again)
|
|
23
|
+
* or backward (Ctrl+S) modulo the result length.
|
|
24
|
+
*
|
|
25
|
+
* Contract:
|
|
26
|
+
* - `searchHistory(query, entries, options)` is pure. No I/O.
|
|
27
|
+
* - Empty query returns ALL entries newest-first so the operator
|
|
28
|
+
* can browse without typing.
|
|
29
|
+
* - Matching is case-insensitive on ASCII; non-ASCII characters
|
|
30
|
+
* compare as-is.
|
|
31
|
+
* - `cycle(state, direction)` is a no-op when there are no matches.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_LIMIT = 50;
|
|
34
|
+
const WORD_BOUNDARIES = new Set([' ', '/', '-', '_', '.', ',', ':', ';', '\t']);
|
|
35
|
+
/**
|
|
36
|
+
* Score one candidate against the query. Returns `null` when the
|
|
37
|
+
* query cannot be matched as an in-order substring (not necessarily
|
|
38
|
+
* contiguous). The matched positions are returned so the UI can
|
|
39
|
+
* underline them.
|
|
40
|
+
*/
|
|
41
|
+
export function scoreCandidate(query, candidate) {
|
|
42
|
+
if (query.length === 0) {
|
|
43
|
+
return { score: 0, positions: [] };
|
|
44
|
+
}
|
|
45
|
+
const lowerQuery = query.toLowerCase();
|
|
46
|
+
const lowerCandidate = candidate.toLowerCase();
|
|
47
|
+
// Walk the candidate, greedily matching each query char in order.
|
|
48
|
+
const positions = [];
|
|
49
|
+
let qIdx = 0;
|
|
50
|
+
for (let i = 0; i < lowerCandidate.length && qIdx < lowerQuery.length; i += 1) {
|
|
51
|
+
if (lowerCandidate[i] === lowerQuery[qIdx]) {
|
|
52
|
+
positions.push(i);
|
|
53
|
+
qIdx += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (qIdx < lowerQuery.length)
|
|
57
|
+
return null;
|
|
58
|
+
// Score the match.
|
|
59
|
+
let score = 0;
|
|
60
|
+
// Prefix bonus.
|
|
61
|
+
if (positions[0] === 0)
|
|
62
|
+
score += 50;
|
|
63
|
+
// Substring contiguity + boundary bonuses.
|
|
64
|
+
let lastPos = -2;
|
|
65
|
+
for (let p = 0; p < positions.length; p += 1) {
|
|
66
|
+
const pos = positions[p];
|
|
67
|
+
// Contiguous run.
|
|
68
|
+
if (pos === lastPos + 1) {
|
|
69
|
+
score += 5;
|
|
70
|
+
}
|
|
71
|
+
else if (lastPos >= 0) {
|
|
72
|
+
// Distance penalty for non-contiguous matches.
|
|
73
|
+
score -= pos - lastPos - 1;
|
|
74
|
+
}
|
|
75
|
+
// Word-boundary start.
|
|
76
|
+
if (pos === 0) {
|
|
77
|
+
// Already counted as prefix.
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const prev = candidate[pos - 1] ?? '';
|
|
81
|
+
if (WORD_BOUNDARIES.has(prev)) {
|
|
82
|
+
score += 20;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Camel boundary: prev is lowercase, current is uppercase.
|
|
86
|
+
const cur = candidate[pos] ?? '';
|
|
87
|
+
if (prev === prev.toLowerCase() && cur !== cur.toLowerCase()) {
|
|
88
|
+
score += 10;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
lastPos = pos;
|
|
93
|
+
}
|
|
94
|
+
return { score, positions };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Search a history list for matches. Entries are passed oldest-first
|
|
98
|
+
* (the format `history.read` returns); the recency bonus uses the
|
|
99
|
+
* original index so newer entries win ties.
|
|
100
|
+
*/
|
|
101
|
+
export function searchHistory(query, entries, options = {}) {
|
|
102
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
103
|
+
const total = entries.length;
|
|
104
|
+
if (query.length === 0) {
|
|
105
|
+
// Empty query - browse mode. Return newest-first up to limit, no
|
|
106
|
+
// scoring needed.
|
|
107
|
+
const out = [];
|
|
108
|
+
for (let i = total - 1; i >= 0 && out.length < limit; i -= 1) {
|
|
109
|
+
const brief = entries[i];
|
|
110
|
+
out.push({ brief, positions: [], score: 0, originalIndex: i });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
const candidates = [];
|
|
115
|
+
for (let i = 0; i < total; i += 1) {
|
|
116
|
+
const brief = entries[i];
|
|
117
|
+
const scored = scoreCandidate(query, brief);
|
|
118
|
+
if (!scored)
|
|
119
|
+
continue;
|
|
120
|
+
// Recency bonus: 0..0.5 weight scaled by index/total.
|
|
121
|
+
const recency = total === 0 ? 0 : (i / total) * 0.5;
|
|
122
|
+
candidates.push({
|
|
123
|
+
brief,
|
|
124
|
+
positions: scored.positions,
|
|
125
|
+
score: scored.score + recency,
|
|
126
|
+
originalIndex: i,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
candidates.sort((a, b) => {
|
|
130
|
+
if (b.score !== a.score)
|
|
131
|
+
return b.score - a.score;
|
|
132
|
+
// Tie-breaker: newer first.
|
|
133
|
+
return b.originalIndex - a.originalIndex;
|
|
134
|
+
});
|
|
135
|
+
return candidates.slice(0, limit);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Initial search state for a fresh Ctrl+R press. Empty query +
|
|
139
|
+
* focusedIndex 0 over the browse list.
|
|
140
|
+
*/
|
|
141
|
+
export function initialSearchState(entries) {
|
|
142
|
+
const matches = searchHistory('', entries);
|
|
143
|
+
return { query: '', matches, focusedIndex: 0 };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Update the search state when the operator types a new query char or
|
|
147
|
+
* removes one. The focused index is clamped to the new match list so
|
|
148
|
+
* the UI never points off-the-end.
|
|
149
|
+
*/
|
|
150
|
+
export function applyQuery(state, query, entries) {
|
|
151
|
+
const matches = searchHistory(query, entries);
|
|
152
|
+
const focusedIndex = matches.length === 0 ? 0 : Math.min(state.focusedIndex, matches.length - 1);
|
|
153
|
+
return { query, matches, focusedIndex };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Move the focused match. `+1` = next (Ctrl+R repeat), `-1` = previous
|
|
157
|
+
* (Ctrl+S). Wraps around modulo result length so the operator never
|
|
158
|
+
* has to chase the end.
|
|
159
|
+
*/
|
|
160
|
+
export function cycle(state, direction) {
|
|
161
|
+
if (state.matches.length === 0)
|
|
162
|
+
return state;
|
|
163
|
+
const len = state.matches.length;
|
|
164
|
+
const next = (state.focusedIndex + direction + len) % len;
|
|
165
|
+
return { ...state, focusedIndex: next };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Pull the brief from the currently focused match, or `null` when
|
|
169
|
+
* the result list is empty (Enter accepts nothing).
|
|
170
|
+
*/
|
|
171
|
+
export function currentBrief(state) {
|
|
172
|
+
const match = state.matches[state.focusedIndex];
|
|
173
|
+
return match ? match.brief : null;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=history-search.js.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent REPL history (per-workspace) - Sprint α6.14.
|
|
3
|
+
*
|
|
4
|
+
* Stores submitted briefs in `~/.pugi/history/<workspace-slug>.jsonl`,
|
|
5
|
+
* one JSON object per line. The format is line-delimited JSON so the
|
|
6
|
+
* file can be appended to atomically (single `write(2)` per entry on
|
|
7
|
+
* Linux/macOS for entries < PIPE_BUF) and tailed by humans without a
|
|
8
|
+
* parser. Per-workspace separation lets the operator switch repos and
|
|
9
|
+
* keep brief history contextual: `brief: fix the cabinet sidebar 401`
|
|
10
|
+
* does not bleed into the agents repo.
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
*
|
|
14
|
+
* - `append({ home, workspaceSlug, brief })` writes one line. Dedups
|
|
15
|
+
* a brief that is identical to the immediately preceding entry
|
|
16
|
+
* (most common operator pattern: Up + Enter to re-run).
|
|
17
|
+
* - `read({ home, workspaceSlug })` returns entries oldest-first so
|
|
18
|
+
* the caller can navigate with `index = entries.length - 1` for
|
|
19
|
+
* "most recent" semantics.
|
|
20
|
+
* - The file is capped at MAX_ENTRIES; on overflow we keep the most
|
|
21
|
+
* recent slice and rewrite. Cheap because briefs are short text
|
|
22
|
+
* and the cap is 1000.
|
|
23
|
+
* - `slugForCwd(cwd)` normalises a working directory into a safe
|
|
24
|
+
* filename component (alphanumerics + `-`, lowercase, slashes
|
|
25
|
+
* collapsed). Empty cwd resolves to `default`.
|
|
26
|
+
* - Failures (missing $HOME, disk full, EACCES) NEVER throw. History
|
|
27
|
+
* is operator comfort, not a contract surface; degrading to "no
|
|
28
|
+
* history this session" is correct.
|
|
29
|
+
*
|
|
30
|
+
* Brand voice: file is operator-facing if they `cat` it, so the JSON
|
|
31
|
+
* keys stay readable English (`brief`, `ts`). No forbidden words.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync, unlinkSync, } from 'node:fs';
|
|
34
|
+
import { homedir } from 'node:os';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
/** Cap on stored entries per workspace. Drops oldest on overflow. */
|
|
37
|
+
export const MAX_HISTORY_ENTRIES = 1000;
|
|
38
|
+
/**
|
|
39
|
+
* Compute the on-disk path for a given workspace slug. Tests rely on
|
|
40
|
+
* this to assert per-workspace isolation without re-implementing the
|
|
41
|
+
* directory math.
|
|
42
|
+
*/
|
|
43
|
+
export function historyPath(io) {
|
|
44
|
+
const home = io.home ?? homedir();
|
|
45
|
+
const safe = sanitiseSlug(io.workspaceSlug);
|
|
46
|
+
return join(home, '.pugi', 'history', `${safe}.jsonl`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Append a brief to history. Dedups consecutive identical entries.
|
|
50
|
+
* Returns the entry that was written, or `null` when the entry was
|
|
51
|
+
* deduped or the brief was empty.
|
|
52
|
+
*/
|
|
53
|
+
export function append(input) {
|
|
54
|
+
const brief = input.brief.trim();
|
|
55
|
+
if (brief.length === 0)
|
|
56
|
+
return null;
|
|
57
|
+
const path = historyPath(input);
|
|
58
|
+
try {
|
|
59
|
+
ensureDir(dirname(path));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const existing = read({ home: input.home, workspaceSlug: input.workspaceSlug });
|
|
65
|
+
const last = existing[existing.length - 1];
|
|
66
|
+
if (last && last.brief === brief) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const ts = (input.now ?? (() => new Date()))().toISOString();
|
|
70
|
+
const entry = { ts, brief };
|
|
71
|
+
// Overflow path: combined length > cap means we trim before rewrite.
|
|
72
|
+
// Write to a sibling tmp file and renameSync over the target so
|
|
73
|
+
// concurrent CLI instances in the same workspace cannot observe a
|
|
74
|
+
// half-written file or race a parallel appendFileSync into oblivion.
|
|
75
|
+
// POSIX renameSync is atomic within a directory; on Windows fs.rename
|
|
76
|
+
// is atomic too as long as both paths are on the same volume (the tmp
|
|
77
|
+
// sibling guarantees that). P2 fix from PR #335 triple-review.
|
|
78
|
+
if (existing.length + 1 > MAX_HISTORY_ENTRIES) {
|
|
79
|
+
const trimmed = [...existing.slice(existing.length + 1 - MAX_HISTORY_ENTRIES), entry];
|
|
80
|
+
const tmpPath = `${path}.tmp`;
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(tmpPath, trimmed.map(serialize).join('\n') + '\n', { mode: 0o600 });
|
|
83
|
+
renameSync(tmpPath, path);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Best-effort cleanup of the orphan tmp file; never throw out.
|
|
87
|
+
try {
|
|
88
|
+
unlinkSync(tmpPath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* ignore — tmp file may not exist yet */
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return entry;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
appendFileSync(path, serialize(entry) + '\n', { mode: 0o600 });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return entry;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read history for a workspace, oldest-first. Returns `[]` when the
|
|
107
|
+
* file is missing, unreadable, or empty. Malformed lines are dropped
|
|
108
|
+
* silently - one bad line should not nuke the whole history.
|
|
109
|
+
*/
|
|
110
|
+
export function read(io) {
|
|
111
|
+
const path = historyPath(io);
|
|
112
|
+
if (!existsSync(path))
|
|
113
|
+
return [];
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = readFileSync(path, 'utf8');
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const line of raw.split('\n')) {
|
|
123
|
+
const trimmed = line.trim();
|
|
124
|
+
if (trimmed.length === 0)
|
|
125
|
+
continue;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(trimmed);
|
|
128
|
+
if (typeof parsed === 'object' &&
|
|
129
|
+
parsed !== null &&
|
|
130
|
+
typeof parsed.brief === 'string' &&
|
|
131
|
+
typeof parsed.ts === 'string') {
|
|
132
|
+
out.push({
|
|
133
|
+
ts: parsed.ts,
|
|
134
|
+
brief: parsed.brief,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Drop malformed line.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Normalise a cwd or workspace name into a safe filename component.
|
|
146
|
+
* Lowercase, alphanumerics + `-` only. Slashes become `-`. Empty input
|
|
147
|
+
* resolves to `default` so we never produce an empty filename.
|
|
148
|
+
*/
|
|
149
|
+
export function slugForCwd(cwd) {
|
|
150
|
+
if (!cwd || cwd.trim().length === 0)
|
|
151
|
+
return 'default';
|
|
152
|
+
// Strip leading slash so `/Users/foo` becomes `users-foo`.
|
|
153
|
+
const normalised = cwd
|
|
154
|
+
.replace(/^[/\\]+/, '')
|
|
155
|
+
.replace(/[/\\]+/g, '-')
|
|
156
|
+
.toLowerCase();
|
|
157
|
+
return sanitiseSlug(normalised);
|
|
158
|
+
}
|
|
159
|
+
function sanitiseSlug(raw) {
|
|
160
|
+
const cleaned = raw.replace(/[^a-z0-9-]/gi, '-').toLowerCase().replace(/-+/g, '-');
|
|
161
|
+
const trimmed = cleaned.replace(/^-+|-+$/g, '');
|
|
162
|
+
return trimmed.length === 0 ? 'default' : trimmed;
|
|
163
|
+
}
|
|
164
|
+
function serialize(entry) {
|
|
165
|
+
return JSON.stringify(entry);
|
|
166
|
+
}
|
|
167
|
+
function ensureDir(dir) {
|
|
168
|
+
if (existsSync(dir))
|
|
169
|
+
return;
|
|
170
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=history.js.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kill ring for the REPL input - Sprint α6.14.
|
|
3
|
+
*
|
|
4
|
+
* Tiny LIFO buffer that backs the readline-style kill commands:
|
|
5
|
+
*
|
|
6
|
+
* Ctrl+U - kill from cursor to line start
|
|
7
|
+
* Ctrl+K - kill from cursor to line end
|
|
8
|
+
* Ctrl+W - kill word backwards (whitespace + punctuation delimiter)
|
|
9
|
+
* Ctrl+Y - yank the most recent kill at the cursor
|
|
10
|
+
*
|
|
11
|
+
* The ring is bounded (MAX_RING_ENTRIES = 10) so the operator's
|
|
12
|
+
* recent kills are reachable without leaking memory across long
|
|
13
|
+
* sessions. We do NOT implement Meta+Y (cycle yanks) at this layer -
|
|
14
|
+
* sticking to the most-recent-yank keeps the input box logic small
|
|
15
|
+
* and matches the bash default for new operators.
|
|
16
|
+
*
|
|
17
|
+
* The module is pure functional: every operation returns a NEW ring,
|
|
18
|
+
* so the input box can stash one in `useState` without mutation
|
|
19
|
+
* worries. Empty slices are no-ops (we do not push empty strings) so
|
|
20
|
+
* Ctrl+U at column 0 does not pollute the ring with `""`.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_RING_ENTRIES = 10;
|
|
23
|
+
export const EMPTY_KILL_RING = { entries: [] };
|
|
24
|
+
/**
|
|
25
|
+
* Push a slice into the ring. Returns a new ring with the slice at
|
|
26
|
+
* the front; older entries shift right and the tail is dropped if
|
|
27
|
+
* the cap is exceeded. Empty / whitespace-only slices are a no-op so
|
|
28
|
+
* the ring stays meaningful.
|
|
29
|
+
*/
|
|
30
|
+
export function push(ring, slice) {
|
|
31
|
+
if (slice.length === 0)
|
|
32
|
+
return ring;
|
|
33
|
+
const next = [slice, ...ring.entries];
|
|
34
|
+
return { entries: next.slice(0, MAX_RING_ENTRIES) };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the most-recent entry without mutating the ring. Returns
|
|
38
|
+
* `null` when the ring is empty so the caller can decide whether to
|
|
39
|
+
* beep, no-op, or fall through to plain insert.
|
|
40
|
+
*/
|
|
41
|
+
export function yank(ring) {
|
|
42
|
+
return ring.entries.length > 0 ? ring.entries[0] : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Word delimiter used by Ctrl+W. Whitespace + ASCII punctuation,
|
|
46
|
+
* matching the readline default. We treat `_` and `-` as part of the
|
|
47
|
+
* word so kebab-case and snake_case identifiers behave as one token
|
|
48
|
+
* (the brief frequently mentions filenames + symbols).
|
|
49
|
+
*/
|
|
50
|
+
const WORD_DELIMITERS = new Set([
|
|
51
|
+
' ', '\t', '\n',
|
|
52
|
+
'.', ',', ';', ':',
|
|
53
|
+
'/', '\\',
|
|
54
|
+
'!', '?', '@', '#', '$', '%', '^', '&', '*',
|
|
55
|
+
'(', ')', '[', ']', '{', '}', '<', '>',
|
|
56
|
+
'"', "'", '`',
|
|
57
|
+
'=', '+', '|', '~',
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* Compute the start of the previous word given a cursor position.
|
|
61
|
+
* Walks LEFT from `cursor - 1`, skipping any delimiters that
|
|
62
|
+
* immediately precede the cursor (so Ctrl+W at the end of `foo `
|
|
63
|
+
* still kills `foo`), then continues left until it hits the next
|
|
64
|
+
* delimiter or the start of the line.
|
|
65
|
+
*
|
|
66
|
+
* Returns the offset BEFORE which the kill should start (i.e. the
|
|
67
|
+
* slice to remove is `line.slice(start, cursor)`).
|
|
68
|
+
*/
|
|
69
|
+
export function previousWordStart(line, cursor) {
|
|
70
|
+
let i = Math.min(cursor, line.length) - 1;
|
|
71
|
+
// Skip trailing delimiters.
|
|
72
|
+
while (i >= 0 && WORD_DELIMITERS.has(line[i]))
|
|
73
|
+
i -= 1;
|
|
74
|
+
// Walk through the word.
|
|
75
|
+
while (i >= 0 && !WORD_DELIMITERS.has(line[i]))
|
|
76
|
+
i -= 1;
|
|
77
|
+
return i + 1;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Apply a Ctrl+U kill: from cursor to line start. Returns the new
|
|
81
|
+
* line + cursor + ring. No-op when cursor is already at column 0.
|
|
82
|
+
*/
|
|
83
|
+
export function killToLineStart(line, cursor, ring) {
|
|
84
|
+
if (cursor === 0)
|
|
85
|
+
return { line, cursor, ring };
|
|
86
|
+
const slice = line.slice(0, cursor);
|
|
87
|
+
return {
|
|
88
|
+
line: line.slice(cursor),
|
|
89
|
+
cursor: 0,
|
|
90
|
+
ring: push(ring, slice),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Apply a Ctrl+K kill: from cursor to line end. Returns the new
|
|
95
|
+
* line + cursor + ring. No-op when cursor is already past the last
|
|
96
|
+
* character.
|
|
97
|
+
*/
|
|
98
|
+
export function killToLineEnd(line, cursor, ring) {
|
|
99
|
+
if (cursor >= line.length)
|
|
100
|
+
return { line, cursor, ring };
|
|
101
|
+
const slice = line.slice(cursor);
|
|
102
|
+
return {
|
|
103
|
+
line: line.slice(0, cursor),
|
|
104
|
+
cursor,
|
|
105
|
+
ring: push(ring, slice),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Apply a Ctrl+W kill: from cursor back to the start of the previous
|
|
110
|
+
* word. Returns the new line + cursor + ring. No-op when cursor is
|
|
111
|
+
* at column 0.
|
|
112
|
+
*/
|
|
113
|
+
export function killWordBackward(line, cursor, ring) {
|
|
114
|
+
if (cursor === 0)
|
|
115
|
+
return { line, cursor, ring };
|
|
116
|
+
const start = previousWordStart(line, cursor);
|
|
117
|
+
const slice = line.slice(start, cursor);
|
|
118
|
+
return {
|
|
119
|
+
line: line.slice(0, start) + line.slice(cursor),
|
|
120
|
+
cursor: start,
|
|
121
|
+
ring: push(ring, slice),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Apply a Ctrl+Y yank: insert the most-recent entry at the cursor.
|
|
126
|
+
* Returns the new line + cursor unchanged when the ring is empty
|
|
127
|
+
* (the caller decides whether to surface a visual cue).
|
|
128
|
+
*/
|
|
129
|
+
export function yankAtCursor(line, cursor, ring) {
|
|
130
|
+
const entry = yank(ring);
|
|
131
|
+
if (entry === null)
|
|
132
|
+
return { line, cursor };
|
|
133
|
+
return {
|
|
134
|
+
line: line.slice(0, cursor) + entry + line.slice(cursor),
|
|
135
|
+
cursor: cursor + entry.length,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=kill-ring.js.map
|