@giovannijecha/jecode 0.7.3 → 0.7.4
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 +81 -18
- package/dist/atomic.js +14 -0
- package/dist/batch.js +4 -1
- package/dist/context/policy.js +14 -7
- package/dist/controller-request.js +1 -1
- package/dist/controller.js +5 -4
- package/dist/oauth-http.js +2 -1
- package/dist/openai-oauth-callback.js +2 -1
- package/dist/providers/http.js +4 -3
- package/dist/providers/openai-codex.js +0 -2
- package/dist/providers/sse.js +115 -38
- package/dist/sessions/store.js +2 -1
- package/dist/text-boundary.js +47 -0
- package/dist/timeline.js +2 -1
- package/dist/tools/fs.js +5 -0
- package/dist/tools/text-boundary.js +1 -31
- package/dist/tui/app-workflows.js +2 -1
- package/dist/tui/components/messages.js +2 -5
- package/dist/tui/components/tool.js +7 -2
- package/dist/tui/turn.js +29 -8
- package/dist/ui/diff.js +51 -16
- package/dist/ui/render.js +16 -24
- package/dist/ui/width.js +31 -22
- package/dist/usage.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,10 +53,32 @@
|
|
|
53
53
|
|
|
54
54
|
Jecode requires **Node.js 22.18+ on the 22.x line, or Node.js 24+**, and npm.
|
|
55
55
|
|
|
56
|
+
### Install or update
|
|
57
|
+
|
|
58
|
+
Install the current stable release. Running the same command again updates an
|
|
59
|
+
existing installation:
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
npm install --global @giovannijecha/jecode@latest
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Confirm the installed version:
|
|
66
|
+
|
|
56
67
|
```console
|
|
57
|
-
npm install --global @giovannijecha/jecode
|
|
58
68
|
jecode --version
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Start Jecode
|
|
72
|
+
|
|
73
|
+
Open the project you want Jecode to work on:
|
|
74
|
+
|
|
75
|
+
```console
|
|
59
76
|
cd path/to/your/project
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then start Jecode:
|
|
80
|
+
|
|
81
|
+
```console
|
|
60
82
|
jecode
|
|
61
83
|
```
|
|
62
84
|
|
|
@@ -71,33 +93,41 @@ change to improve startup performance.
|
|
|
71
93
|
Use `jecode --root path/to/project` to select another workspace, or
|
|
72
94
|
`jecode --ephemeral` when the conversation must stay memory-only.
|
|
73
95
|
|
|
74
|
-
Resume a
|
|
96
|
+
### Resume a conversation
|
|
97
|
+
|
|
98
|
+
Open the searchable resume picker for the current workspace:
|
|
75
99
|
|
|
76
100
|
```console
|
|
77
101
|
jecode resume
|
|
78
|
-
jecode resume --latest
|
|
79
102
|
```
|
|
80
103
|
|
|
81
|
-
|
|
82
|
-
covered by the project test matrix.
|
|
104
|
+
Resume the most recently updated conversation directly:
|
|
83
105
|
|
|
84
|
-
|
|
106
|
+
```console
|
|
107
|
+
jecode resume --latest
|
|
108
|
+
```
|
|
85
109
|
|
|
86
|
-
|
|
87
|
-
opt-in prerelease:
|
|
110
|
+
List every startup option:
|
|
88
111
|
|
|
89
112
|
```console
|
|
90
|
-
|
|
113
|
+
jecode --help
|
|
91
114
|
```
|
|
92
115
|
|
|
116
|
+
Windows, Ubuntu, and macOS are covered by the project test matrix.
|
|
117
|
+
|
|
118
|
+
### Prereleases
|
|
119
|
+
|
|
120
|
+
Prereleases exist only during an announced release-candidate cycle. When one is
|
|
121
|
+
active, its GitHub release provides the exact installation command. Outside an
|
|
122
|
+
active cycle, the stable npm package is the only supported installation
|
|
123
|
+
artifact.
|
|
124
|
+
|
|
93
125
|
Git URL installs are intentionally unsupported: the source tree contains no
|
|
94
126
|
generated runtime and defines no install-time build hook.
|
|
95
127
|
|
|
96
|
-
###
|
|
128
|
+
### Uninstall
|
|
97
129
|
|
|
98
130
|
```console
|
|
99
|
-
npm install --global @giovannijecha/jecode
|
|
100
|
-
jecode --version
|
|
101
131
|
npm uninstall --global @giovannijecha/jecode
|
|
102
132
|
```
|
|
103
133
|
|
|
@@ -105,12 +135,19 @@ Uninstalling the command preserves `~/.jecode`. Remove that directory only when
|
|
|
105
135
|
you intentionally want to erase saved settings, credentials, accounts, and
|
|
106
136
|
sessions.
|
|
107
137
|
|
|
108
|
-
|
|
109
|
-
|
|
138
|
+
### Replace a legacy installation
|
|
139
|
+
|
|
140
|
+
If an older GitHub installation still owns the `jecode` executable, first
|
|
141
|
+
remove the legacy unscoped package:
|
|
110
142
|
|
|
111
143
|
```console
|
|
112
144
|
npm uninstall --global jecode
|
|
113
|
-
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Then install the current scoped package:
|
|
148
|
+
|
|
149
|
+
```console
|
|
150
|
+
npm install --global @giovannijecha/jecode@latest
|
|
114
151
|
```
|
|
115
152
|
|
|
116
153
|
Do not work around the resulting `EEXIST` error with `--force`.
|
|
@@ -119,14 +156,39 @@ Do not work around the resulting `EEXIST` error with `--force`.
|
|
|
119
156
|
|
|
120
157
|
WSL uses its own Node.js installation and `PATH`; the Node.js version installed
|
|
121
158
|
on Windows does not apply inside it. Keep user-installed npm commands in the
|
|
122
|
-
Linux user path:
|
|
159
|
+
Linux user path. Set the user-level npm prefix:
|
|
123
160
|
|
|
124
161
|
```console
|
|
125
162
|
npm config set prefix "$HOME/.local"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Add it to the current shell's `PATH`:
|
|
166
|
+
|
|
167
|
+
```console
|
|
126
168
|
export PATH="$HOME/.local/bin:$PATH"
|
|
127
|
-
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Install or update Jecode:
|
|
172
|
+
|
|
173
|
+
```console
|
|
174
|
+
npm install --global @giovannijecha/jecode@latest
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Refresh the command cache:
|
|
178
|
+
|
|
179
|
+
```console
|
|
128
180
|
hash -r
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Confirm which executable will run:
|
|
184
|
+
|
|
185
|
+
```console
|
|
129
186
|
command -v jecode
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Verify the installed version:
|
|
190
|
+
|
|
191
|
+
```console
|
|
130
192
|
jecode --version
|
|
131
193
|
```
|
|
132
194
|
|
|
@@ -347,7 +409,8 @@ fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
|
|
|
347
409
|
probes for long-session rendering and workspace search. Architecture and
|
|
348
410
|
security boundaries are documented in
|
|
349
411
|
[docs/architecture.md](docs/architecture.md); brand assets and usage rules live
|
|
350
|
-
in [docs/brand.md](docs/brand.md).
|
|
412
|
+
in [docs/brand.md](docs/brand.md). The maintainer release procedure lives in
|
|
413
|
+
[docs/releasing.md](docs/releasing.md).
|
|
351
414
|
|
|
352
415
|
## Community
|
|
353
416
|
|
package/dist/atomic.js
CHANGED
|
@@ -8,20 +8,29 @@ export async function atomicWrite(file, content, options = {}) {
|
|
|
8
8
|
let handle;
|
|
9
9
|
let identity;
|
|
10
10
|
try {
|
|
11
|
+
throwIfAborted(options.signal);
|
|
11
12
|
await options.validate?.("before-open");
|
|
13
|
+
throwIfAborted(options.signal);
|
|
12
14
|
const permissions = options.mode ?? (await existingMode(file));
|
|
15
|
+
throwIfAborted(options.signal);
|
|
13
16
|
handle = await open(temporary, "wx", permissions);
|
|
14
17
|
identity = fileIdentity(await handle.stat());
|
|
18
|
+
throwIfAborted(options.signal);
|
|
15
19
|
await options.validate?.("before-write");
|
|
20
|
+
throwIfAborted(options.signal);
|
|
16
21
|
await assertNamedFile(temporary, identity);
|
|
17
22
|
await handle.writeFile(content, "utf8");
|
|
23
|
+
throwIfAborted(options.signal);
|
|
18
24
|
if (permissions !== undefined && process.platform !== "win32") {
|
|
19
25
|
await handle.chmod(permissions);
|
|
20
26
|
}
|
|
21
27
|
await handle.sync();
|
|
28
|
+
throwIfAborted(options.signal);
|
|
22
29
|
identity = fileIdentity(await handle.stat());
|
|
23
30
|
await options.validate?.("before-rename");
|
|
31
|
+
throwIfAborted(options.signal);
|
|
24
32
|
await assertNamedFile(temporary, identity);
|
|
33
|
+
throwIfAborted(options.signal);
|
|
25
34
|
await rename(temporary, file);
|
|
26
35
|
const completed = handle;
|
|
27
36
|
handle = undefined;
|
|
@@ -38,6 +47,11 @@ export async function atomicWrite(file, content, options = {}) {
|
|
|
38
47
|
throw error;
|
|
39
48
|
}
|
|
40
49
|
}
|
|
50
|
+
function throwIfAborted(signal) {
|
|
51
|
+
if (signal?.aborted !== true)
|
|
52
|
+
return;
|
|
53
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
54
|
+
}
|
|
41
55
|
async function assertNamedFile(file, expected) {
|
|
42
56
|
const details = await lstat(file);
|
|
43
57
|
if (details.isSymbolicLink() || !sameFile(expected, fileIdentity(details))) {
|
package/dist/batch.js
CHANGED
|
@@ -13,7 +13,7 @@ import { handleCommand } from "./commands.js";
|
|
|
13
13
|
import { renderBatch } from "./batch-view.js";
|
|
14
14
|
import { columns } from "./ui/render.js";
|
|
15
15
|
import { terminalText } from "./ui/terminal-text.js";
|
|
16
|
-
import { recordAuxiliaryUsage, recordUsage } from "./usage.js";
|
|
16
|
+
import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
|
|
17
17
|
export async function runBatch(session, environment = {}) {
|
|
18
18
|
const rl = environment.lines === undefined ? readline.createInterface({ input: stdin }) : undefined;
|
|
19
19
|
const lines = environment.lines ?? rl;
|
|
@@ -167,5 +167,8 @@ function events(emit, session) {
|
|
|
167
167
|
onUsage(usage) {
|
|
168
168
|
recordUsage(session.usage, usage);
|
|
169
169
|
},
|
|
170
|
+
onRequestInput(inputTokens) {
|
|
171
|
+
recordRequestInput(session.usage, inputTokens);
|
|
172
|
+
},
|
|
170
173
|
};
|
|
171
174
|
}
|
package/dist/context/policy.js
CHANGED
|
@@ -42,16 +42,16 @@ export function planCompaction(context, turn, coveredMessages, lastInputTokens,
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
export function policyForContextWindow(context, compactionPercent) {
|
|
45
|
-
const windowTokens =
|
|
46
|
-
?
|
|
47
|
-
:
|
|
45
|
+
const windowTokens = context === undefined
|
|
46
|
+
? FALLBACK_CONTEXT_WINDOW_TOKENS
|
|
47
|
+
: requireWindow(context.tokens);
|
|
48
48
|
const percent = validPercent(compactionPercent)
|
|
49
49
|
? compactionPercent
|
|
50
50
|
: DEFAULT_COMPACTION_PERCENT;
|
|
51
51
|
const percentageLimit = Math.floor(windowTokens * percent / 100);
|
|
52
|
-
const providerLimit =
|
|
53
|
-
?
|
|
54
|
-
:
|
|
52
|
+
const providerLimit = context?.compactAtTokens === undefined
|
|
53
|
+
? windowTokens
|
|
54
|
+
: requireWindow(context.compactAtTokens);
|
|
55
55
|
const requestLimitTokens = Math.floor(Math.min(providerLimit, windowTokens) * (100 - REQUEST_ESTIMATE_HEADROOM_PERCENT) / 100);
|
|
56
56
|
const triggerTokens = Math.min(percentageLimit, requestLimitTokens - MIN_REQUEST_OUTPUT_TOKENS);
|
|
57
57
|
const targetTokens = Math.max(512, Math.min(Math.floor(windowTokens / 4), Math.floor(triggerTokens / 2)));
|
|
@@ -114,7 +114,14 @@ function validPolicy(policy) {
|
|
|
114
114
|
policy.targetTokens < policy.triggerTokens && policy.recentTokens < policy.triggerTokens;
|
|
115
115
|
}
|
|
116
116
|
function validWindow(value) {
|
|
117
|
-
|
|
117
|
+
// Adapters validate raw capacities at 4K before applying their own usable
|
|
118
|
+
// headroom. The resulting safe capacity can therefore be slightly smaller.
|
|
119
|
+
return value !== undefined && Number.isSafeInteger(value) && value >= 1_024 && value <= 10_000_000;
|
|
120
|
+
}
|
|
121
|
+
function requireWindow(value) {
|
|
122
|
+
if (validWindow(value))
|
|
123
|
+
return value;
|
|
124
|
+
throw new Error(`provider reported an invalid usable context window: ${value}`);
|
|
118
125
|
}
|
|
119
126
|
function validPercent(value) {
|
|
120
127
|
return Number.isSafeInteger(value) &&
|
|
@@ -24,7 +24,7 @@ export async function requestAssistant(history, current, specs, options, events,
|
|
|
24
24
|
onStream: (event) => events.onStream(event),
|
|
25
25
|
onStatus: (status) => events.onStatus?.(status),
|
|
26
26
|
});
|
|
27
|
-
return { message, context };
|
|
27
|
+
return { message, context, inputTokens: budget.inputTokens };
|
|
28
28
|
}
|
|
29
29
|
catch (error) {
|
|
30
30
|
if (recovered)
|
package/dist/controller.js
CHANGED
|
@@ -45,10 +45,13 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
45
45
|
throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
|
|
46
46
|
}
|
|
47
47
|
assertToolCallIds(calls);
|
|
48
|
+
if (assistant.usage !== undefined)
|
|
49
|
+
events.onUsage?.(assistant.usage);
|
|
50
|
+
events.onRequestInput?.(assistant.usage !== undefined && assistant.usage.inputTokens > 0
|
|
51
|
+
? assistant.usage.inputTokens
|
|
52
|
+
: response.inputTokens);
|
|
48
53
|
append(assistant);
|
|
49
54
|
if (calls.length === 0) {
|
|
50
|
-
if (assistant.usage !== undefined)
|
|
51
|
-
events.onUsage?.(assistant.usage);
|
|
52
55
|
await checkpoint("completed");
|
|
53
56
|
return; // the model is done — hand back to the user
|
|
54
57
|
}
|
|
@@ -58,8 +61,6 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
58
61
|
const results = [];
|
|
59
62
|
const announced = new Set();
|
|
60
63
|
try {
|
|
61
|
-
if (assistant.usage !== undefined)
|
|
62
|
-
events.onUsage?.(assistant.usage);
|
|
63
64
|
while (results.length < calls.length) {
|
|
64
65
|
const start = results.length;
|
|
65
66
|
const batch = nextBatch(calls, start, options.tools);
|
package/dist/oauth-http.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// These requests never redirect, never retry, and never retain an unbounded
|
|
4
4
|
// response. Authorization codes and refresh tokens must not leak into errors.
|
|
5
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
6
|
const AUTH_ORIGIN = "https://auth.openai.com";
|
|
6
7
|
const TIMEOUT_MS = 15_000;
|
|
7
8
|
const MAX_BODY_CHARS = 64_000;
|
|
@@ -96,7 +97,7 @@ function errorDetail(value, secrets) {
|
|
|
96
97
|
let safe = detail.replace(/[\r\n]+/g, " ");
|
|
97
98
|
for (const secret of secrets)
|
|
98
99
|
safe = safe.replaceAll(secret, "[credential redacted]");
|
|
99
|
-
return ` · ${safe
|
|
100
|
+
return ` · ${leadingText(safe, 300)}`;
|
|
100
101
|
}
|
|
101
102
|
function bodySecrets(body) {
|
|
102
103
|
const values = body.contentType === "application/x-www-form-urlencoded"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
6
|
const CALLBACK_PORTS = [1455, 1457];
|
|
6
7
|
export const OPENAI_CALLBACK_PATH = "/auth/callback";
|
|
7
8
|
export async function openAICallback(state) {
|
|
@@ -42,7 +43,7 @@ export async function openAICallback(state) {
|
|
|
42
43
|
const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
|
|
43
44
|
const authorizationCode = incoming.searchParams.get("code");
|
|
44
45
|
if (authError !== null) {
|
|
45
|
-
rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError
|
|
46
|
+
rejectCode(new Error(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
|
|
46
47
|
}
|
|
47
48
|
else if (authorizationCode === null || authorizationCode === "") {
|
|
48
49
|
rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
|
package/dist/providers/http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The entire HTTP layer: one bounded request, then either a JSON body or an
|
|
2
2
|
// event stream. Only idempotent reads retry. Once a POST starts or response
|
|
3
3
|
// bytes flow, a failure is surfaced rather than silently replayed.
|
|
4
|
+
import { leadingText } from "../text-boundary.js";
|
|
4
5
|
import { readSseJson } from "./sse.js";
|
|
5
6
|
import { sseStreamCharacterLimit } from "./stream-limits.js";
|
|
6
7
|
const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
|
|
@@ -31,7 +32,7 @@ async function asJson(url, res) {
|
|
|
31
32
|
return JSON.parse(text);
|
|
32
33
|
}
|
|
33
34
|
catch {
|
|
34
|
-
throw httpError(`${url} returned non-JSON`, res.status, text
|
|
35
|
+
throw httpError(`${url} returned non-JSON`, res.status, leadingText(text, 500));
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
|
|
@@ -107,13 +108,13 @@ async function boundedText(url, res, max) {
|
|
|
107
108
|
if (done) {
|
|
108
109
|
text += decoder.decode();
|
|
109
110
|
return text.length > max
|
|
110
|
-
? { text: text
|
|
111
|
+
? { text: leadingText(text, max), truncated: true }
|
|
111
112
|
: { text, truncated: false };
|
|
112
113
|
}
|
|
113
114
|
text += decoder.decode(value, { stream: true });
|
|
114
115
|
if (text.length > max) {
|
|
115
116
|
await reader.cancel().catch(() => undefined);
|
|
116
|
-
return { text: text
|
|
117
|
+
return { text: leadingText(text, max), truncated: true };
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
}
|
|
@@ -152,8 +152,6 @@ function modelContextWindow(entry) {
|
|
|
152
152
|
return undefined;
|
|
153
153
|
const percent = percentage(entry["effective_context_window_percent"]) ?? 95;
|
|
154
154
|
const tokens = Math.floor(resolved * percent / 100);
|
|
155
|
-
if (!validTokenCount(tokens))
|
|
156
|
-
return undefined;
|
|
157
155
|
const automatic = Math.floor(resolved * 9 / 10);
|
|
158
156
|
const advertised = tokenCount(entry["auto_compact_token_limit"]);
|
|
159
157
|
return Object.freeze({
|
package/dist/providers/sse.js
CHANGED
|
@@ -7,37 +7,26 @@ import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
|
|
|
7
7
|
export async function* readSseJson(body, maximumChars) {
|
|
8
8
|
const reader = body.getReader();
|
|
9
9
|
const decoder = new TextDecoder();
|
|
10
|
-
|
|
10
|
+
const parser = new SseEventParser();
|
|
11
11
|
let finished = false;
|
|
12
12
|
let total = 0;
|
|
13
|
-
const append = (text) => {
|
|
14
|
-
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
15
|
-
buffer += text;
|
|
16
|
-
};
|
|
17
13
|
try {
|
|
18
14
|
for (;;) {
|
|
19
15
|
const { done, value } = await reader.read();
|
|
20
16
|
if (done)
|
|
21
17
|
break;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
break;
|
|
27
|
-
assertEventSize(boundary.start);
|
|
28
|
-
const chunk = buffer.slice(0, boundary.start);
|
|
29
|
-
buffer = buffer.slice(boundary.end);
|
|
30
|
-
const payload = parseData(chunk);
|
|
31
|
-
if (payload !== undefined)
|
|
32
|
-
yield payload;
|
|
33
|
-
}
|
|
34
|
-
assertEventSize(buffer.length);
|
|
18
|
+
const text = decoder.decode(value, { stream: true });
|
|
19
|
+
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
20
|
+
for (const payload of parser.push(text))
|
|
21
|
+
yield payload;
|
|
35
22
|
}
|
|
36
23
|
// A stream that ends without a trailing blank line still owes us its last
|
|
37
24
|
// event.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const payload
|
|
25
|
+
const text = decoder.decode();
|
|
26
|
+
total = addBounded(total, text.length, maximumChars, "SSE stream");
|
|
27
|
+
for (const payload of parser.push(text))
|
|
28
|
+
yield payload;
|
|
29
|
+
const payload = parser.finish();
|
|
41
30
|
if (payload !== undefined)
|
|
42
31
|
yield payload;
|
|
43
32
|
finished = true;
|
|
@@ -48,28 +37,116 @@ export async function* readSseJson(body, maximumChars) {
|
|
|
48
37
|
reader.releaseLock();
|
|
49
38
|
}
|
|
50
39
|
}
|
|
40
|
+
// Keep fragments in bounded groups. A provider may split one SSE line into
|
|
41
|
+
// hundreds of thousands of tiny chunks; repeatedly flattening the growing line
|
|
42
|
+
// would make parsing quadratic even if boundary scanning itself were linear.
|
|
43
|
+
class TextParts {
|
|
44
|
+
#groups = [];
|
|
45
|
+
#pieces = [];
|
|
46
|
+
#lastCodeUnit = "";
|
|
47
|
+
length = 0;
|
|
48
|
+
append(text) {
|
|
49
|
+
if (text === "")
|
|
50
|
+
return;
|
|
51
|
+
this.#pieces.push(text);
|
|
52
|
+
this.#lastCodeUnit = text.at(-1);
|
|
53
|
+
this.length += text.length;
|
|
54
|
+
if (this.#pieces.length >= 256)
|
|
55
|
+
this.#flush();
|
|
56
|
+
}
|
|
57
|
+
take() {
|
|
58
|
+
this.#flush();
|
|
59
|
+
const text = this.#groups.length === 1 ? this.#groups[0] : this.#groups.join("");
|
|
60
|
+
this.#groups.length = 0;
|
|
61
|
+
this.#lastCodeUnit = "";
|
|
62
|
+
this.length = 0;
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
endsWithCarriageReturn() {
|
|
66
|
+
return this.#lastCodeUnit === "\r";
|
|
67
|
+
}
|
|
68
|
+
#flush() {
|
|
69
|
+
if (this.#pieces.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
this.#groups.push(this.#pieces.length === 1 ? this.#pieces[0] : this.#pieces.join(""));
|
|
72
|
+
this.#pieces = [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export class SseEventParser {
|
|
76
|
+
#line = new TextParts();
|
|
77
|
+
#data = [];
|
|
78
|
+
#eventLength = 0;
|
|
79
|
+
#hasLine = false;
|
|
80
|
+
#pendingTerminatorLength = 0;
|
|
81
|
+
*push(text) {
|
|
82
|
+
let start = 0;
|
|
83
|
+
for (;;) {
|
|
84
|
+
const newline = text.indexOf("\n", start);
|
|
85
|
+
if (newline === -1) {
|
|
86
|
+
this.#line.append(text.slice(start));
|
|
87
|
+
this.#assertPendingLineSize();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.#line.append(text.slice(start, newline));
|
|
91
|
+
const rawLine = this.#line.take();
|
|
92
|
+
const crlf = rawLine.endsWith("\r");
|
|
93
|
+
const line = crlf ? rawLine.slice(0, -1) : rawLine;
|
|
94
|
+
if (line === "") {
|
|
95
|
+
const payload = this.#finishEvent();
|
|
96
|
+
if (payload !== undefined)
|
|
97
|
+
yield payload;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.#appendLine(line, crlf ? 2 : 1);
|
|
101
|
+
}
|
|
102
|
+
start = newline + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
finish() {
|
|
106
|
+
const line = this.#line.take();
|
|
107
|
+
if (line !== "") {
|
|
108
|
+
this.#appendLine(line, 0);
|
|
109
|
+
}
|
|
110
|
+
else if (this.#hasLine) {
|
|
111
|
+
// A single trailing line terminator is part of an unterminated event.
|
|
112
|
+
assertEventSize(this.#eventLength + this.#pendingTerminatorLength);
|
|
113
|
+
}
|
|
114
|
+
return this.#finishEvent();
|
|
115
|
+
}
|
|
116
|
+
#appendLine(line, terminatorLength) {
|
|
117
|
+
const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
|
|
118
|
+
assertEventSize(this.#eventLength + separatorLength + line.length);
|
|
119
|
+
this.#eventLength += separatorLength + line.length;
|
|
120
|
+
this.#hasLine = true;
|
|
121
|
+
this.#pendingTerminatorLength = terminatorLength;
|
|
122
|
+
if (line.startsWith("data:")) {
|
|
123
|
+
this.#data.push(line.slice("data:".length).trimStart());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
#assertPendingLineSize() {
|
|
127
|
+
const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
|
|
128
|
+
const remaining = MAX_SSE_EVENT_CHARS - this.#eventLength - separatorLength;
|
|
129
|
+
// One trailing CR may turn out to be part of a split CRLF terminator.
|
|
130
|
+
const splitCrlf = this.#line.length === remaining + 1 && this.#line.endsWithCarriageReturn();
|
|
131
|
+
if (this.#line.length > remaining && !splitCrlf) {
|
|
132
|
+
assertEventSize(MAX_SSE_EVENT_CHARS + 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
#finishEvent() {
|
|
136
|
+
const data = this.#data.join("\n");
|
|
137
|
+
this.#data = [];
|
|
138
|
+
this.#eventLength = 0;
|
|
139
|
+
this.#hasLine = false;
|
|
140
|
+
this.#pendingTerminatorLength = 0;
|
|
141
|
+
return parseData(data);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
51
144
|
function assertEventSize(length) {
|
|
52
145
|
if (length > MAX_SSE_EVENT_CHARS) {
|
|
53
146
|
throw new Error(`SSE event exceeded ${MAX_SSE_EVENT_CHARS} characters`);
|
|
54
147
|
}
|
|
55
148
|
}
|
|
56
|
-
|
|
57
|
-
// normalising pass would have to cope with a \r\n split across two chunks.
|
|
58
|
-
function findBoundary(buffer) {
|
|
59
|
-
const lf = buffer.indexOf("\n\n");
|
|
60
|
-
const crlf = buffer.indexOf("\r\n\r\n");
|
|
61
|
-
if (lf === -1 && crlf === -1)
|
|
62
|
-
return undefined;
|
|
63
|
-
if (crlf !== -1 && (lf === -1 || crlf < lf))
|
|
64
|
-
return { start: crlf, end: crlf + 4 };
|
|
65
|
-
return { start: lf, end: lf + 2 };
|
|
66
|
-
}
|
|
67
|
-
function parseData(chunk) {
|
|
68
|
-
const data = chunk
|
|
69
|
-
.split(/\r?\n/)
|
|
70
|
-
.filter((line) => line.startsWith("data:"))
|
|
71
|
-
.map((line) => line.slice("data:".length).trimStart())
|
|
72
|
-
.join("\n");
|
|
149
|
+
function parseData(data) {
|
|
73
150
|
if (data === "" || data === "[DONE]")
|
|
74
151
|
return undefined;
|
|
75
152
|
try {
|
package/dist/sessions/store.js
CHANGED
|
@@ -8,6 +8,7 @@ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { atomicWrite } from "../atomic.js";
|
|
10
10
|
import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
|
|
11
|
+
import { leadingText } from "../text-boundary.js";
|
|
11
12
|
import { userDataPath } from "../user-data.js";
|
|
12
13
|
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
|
|
13
14
|
import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
|
|
@@ -367,7 +368,7 @@ function firstUserText(conversation) {
|
|
|
367
368
|
const text = message.content.find((block) => block.kind === "text")?.text
|
|
368
369
|
.replace(/\s+/gu, " ").trim();
|
|
369
370
|
if (text !== undefined && text !== "")
|
|
370
|
-
return text
|
|
371
|
+
return leadingText(text, 160);
|
|
371
372
|
}
|
|
372
373
|
return "Untitled session";
|
|
373
374
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Shared grapheme boundaries for every projection of user-visible text.
|
|
2
|
+
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
3
|
+
export function segmentGraphemes(text) {
|
|
4
|
+
return SEGMENTER.segment(text);
|
|
5
|
+
}
|
|
6
|
+
export function graphemes(text) {
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const { segment } of segmentGraphemes(text))
|
|
9
|
+
out.push(segment);
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
/** Largest complete grapheme boundary no greater than a UTF-16 offset. */
|
|
13
|
+
export function graphemeFloor(text, offset) {
|
|
14
|
+
const target = Math.max(0, Math.min(text.length, offset));
|
|
15
|
+
if (target === 0 || target === text.length)
|
|
16
|
+
return target;
|
|
17
|
+
const containing = segmentGraphemes(text).containing(target);
|
|
18
|
+
if (containing === undefined || containing.index === target)
|
|
19
|
+
return target;
|
|
20
|
+
return containing.index;
|
|
21
|
+
}
|
|
22
|
+
/** Smallest complete grapheme boundary no less than a UTF-16 offset. */
|
|
23
|
+
export function graphemeCeiling(text, offset) {
|
|
24
|
+
const target = Math.max(0, Math.min(text.length, offset));
|
|
25
|
+
if (target === 0 || target === text.length)
|
|
26
|
+
return target;
|
|
27
|
+
const containing = segmentGraphemes(text).containing(target);
|
|
28
|
+
if (containing === undefined || containing.index === target)
|
|
29
|
+
return target;
|
|
30
|
+
return containing.index + containing.segment.length;
|
|
31
|
+
}
|
|
32
|
+
/** Keep a bounded prefix without returning part of a user-perceived character. */
|
|
33
|
+
export function leadingText(text, maxCodeUnits) {
|
|
34
|
+
if (maxCodeUnits <= 0)
|
|
35
|
+
return "";
|
|
36
|
+
if (text.length <= maxCodeUnits)
|
|
37
|
+
return text;
|
|
38
|
+
return text.slice(0, graphemeFloor(text, maxCodeUnits));
|
|
39
|
+
}
|
|
40
|
+
/** Keep a bounded suffix without returning part of a user-perceived character. */
|
|
41
|
+
export function trailingText(text, maxCodeUnits) {
|
|
42
|
+
if (maxCodeUnits <= 0)
|
|
43
|
+
return "";
|
|
44
|
+
if (text.length <= maxCodeUnits)
|
|
45
|
+
return text;
|
|
46
|
+
return text.slice(graphemeCeiling(text, text.length - maxCodeUnits));
|
|
47
|
+
}
|
package/dist/timeline.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Selecting a node changes only the in-memory path. The next real user turn
|
|
4
4
|
// is what persists a branch, so opening or cancelling this control plane can
|
|
5
5
|
// never create empty history.
|
|
6
|
+
import { leadingText } from "./text-boundary.js";
|
|
6
7
|
import { usageFromHistory } from "./usage.js";
|
|
7
8
|
import { heading } from "./tui/picker.js";
|
|
8
9
|
export function timelinePicker(conversation, palette) {
|
|
@@ -83,7 +84,7 @@ function preview(node) {
|
|
|
83
84
|
const text = message.content.find((block) => block.kind === "text")?.text
|
|
84
85
|
.replace(/\s+/gu, " ").trim();
|
|
85
86
|
if (text !== undefined && text !== "")
|
|
86
|
-
return text
|
|
87
|
+
return leadingText(text, 160);
|
|
87
88
|
}
|
|
88
89
|
return "Untitled turn";
|
|
89
90
|
}
|
package/dist/tools/fs.js
CHANGED
|
@@ -120,15 +120,18 @@ export const writeFile = {
|
|
|
120
120
|
},
|
|
121
121
|
};
|
|
122
122
|
export async function runWriteFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
|
|
123
|
+
throwIfAborted(ctx.signal);
|
|
123
124
|
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
124
125
|
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
|
|
125
126
|
const content = requireString(args, "content", true);
|
|
126
127
|
assertEditableText(content);
|
|
127
128
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
129
|
+
throwIfAborted(ctx.signal);
|
|
128
130
|
await assertDirectWritableInRoot(root, target);
|
|
129
131
|
const before = await current(target);
|
|
130
132
|
assertApproved(before, ctx.preview, "write");
|
|
131
133
|
await dependencies.atomicWrite(target, content, {
|
|
134
|
+
signal: ctx.signal,
|
|
132
135
|
async validate(phase) {
|
|
133
136
|
await assertDirectWritableInRoot(root, target);
|
|
134
137
|
if (phase === "before-rename") {
|
|
@@ -181,12 +184,14 @@ export const editFile = {
|
|
|
181
184
|
},
|
|
182
185
|
};
|
|
183
186
|
export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
|
|
187
|
+
throwIfAborted(ctx.signal);
|
|
184
188
|
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
185
189
|
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
186
190
|
const before = await current(target, true);
|
|
187
191
|
assertApproved(before, ctx.preview, "edit");
|
|
188
192
|
const { after, made } = applied(before.text, args);
|
|
189
193
|
await dependencies.atomicWrite(target, after, {
|
|
194
|
+
signal: ctx.signal,
|
|
190
195
|
async validate(phase) {
|
|
191
196
|
await assertDirectWritableInRoot(root, target, true);
|
|
192
197
|
if (phase === "before-rename") {
|
|
@@ -1,41 +1,11 @@
|
|
|
1
1
|
// Shared size and truncation boundaries for text handled by workspace tools.
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { lstat, open } from "node:fs/promises";
|
|
4
|
+
export { leadingText, trailingText } from "../text-boundary.js";
|
|
4
5
|
export const MAX_EDITABLE_BYTES = 4_000_000;
|
|
5
6
|
export const MAX_EDITABLE_CHARS = 1_000_000;
|
|
6
7
|
export const MAX_EDITABLE_LINES = 20_000;
|
|
7
8
|
const READ_CHUNK_BYTES = 64 * 1024;
|
|
8
|
-
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
9
|
-
/** Keep a bounded prefix without returning part of a user-perceived character. */
|
|
10
|
-
export function leadingText(text, maxCodeUnits) {
|
|
11
|
-
if (maxCodeUnits <= 0)
|
|
12
|
-
return "";
|
|
13
|
-
if (text.length <= maxCodeUnits)
|
|
14
|
-
return text;
|
|
15
|
-
let end = 0;
|
|
16
|
-
for (const { index, segment } of GRAPHEME_SEGMENTER.segment(text)) {
|
|
17
|
-
const next = index + segment.length;
|
|
18
|
-
if (next > maxCodeUnits)
|
|
19
|
-
break;
|
|
20
|
-
end = next;
|
|
21
|
-
}
|
|
22
|
-
return text.slice(0, end);
|
|
23
|
-
}
|
|
24
|
-
/** Keep a bounded suffix without returning part of a user-perceived character. */
|
|
25
|
-
export function trailingText(text, maxCodeUnits) {
|
|
26
|
-
if (maxCodeUnits <= 0)
|
|
27
|
-
return "";
|
|
28
|
-
if (text.length <= maxCodeUnits)
|
|
29
|
-
return text;
|
|
30
|
-
let start = text.length;
|
|
31
|
-
for (const { index } of GRAPHEME_SEGMENTER.segment(text)) {
|
|
32
|
-
if (text.length - index <= maxCodeUnits) {
|
|
33
|
-
start = index;
|
|
34
|
-
break;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return text.slice(start);
|
|
38
|
-
}
|
|
39
9
|
/** Read a regular UTF-8 file without allowing an unbounded allocation. */
|
|
40
10
|
export async function readEditableText(file, options = {}) {
|
|
41
11
|
const label = options.label ?? "file";
|
|
@@ -7,7 +7,7 @@ import { compactSession } from "../context/manual.js";
|
|
|
7
7
|
import { isContextOverflow } from "../context/policy.js";
|
|
8
8
|
import { updateSettings } from "../settings.js";
|
|
9
9
|
import { saveTranscript } from "../transcript-export.js";
|
|
10
|
-
import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
|
|
10
|
+
import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "../usage.js";
|
|
11
11
|
import { selectTimeline } from "../timeline.js";
|
|
12
12
|
import { answerAt } from "./approve.js";
|
|
13
13
|
import * as edit from "./editor.js";
|
|
@@ -160,6 +160,7 @@ export function appWorkflows(options) {
|
|
|
160
160
|
state.status = text;
|
|
161
161
|
},
|
|
162
162
|
usage: (usage) => recordUsage(session.usage, usage),
|
|
163
|
+
requestInput: (inputTokens) => recordRequestInput(session.usage, inputTokens),
|
|
163
164
|
});
|
|
164
165
|
const persist = async (checkpoint, settlement, failure) => {
|
|
165
166
|
const next = session.conversation.commit({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trailingText } from "../../text-boundary.js";
|
|
1
2
|
import { blank, row } from "../../ui/render.js";
|
|
2
3
|
import { markdown } from "../../ui/markdown.js";
|
|
3
4
|
const PAD = 1;
|
|
@@ -42,9 +43,5 @@ export function reasoningPreviewSource(text, width) {
|
|
|
42
43
|
return { text, truncated: false };
|
|
43
44
|
// A compact view only needs its visible tail. The complete text remains on
|
|
44
45
|
// the block for expansion after the reasoning stream is sealed.
|
|
45
|
-
|
|
46
|
-
const code = text.charCodeAt(start);
|
|
47
|
-
if (code >= 0xdc00 && code <= 0xdfff)
|
|
48
|
-
start--;
|
|
49
|
-
return { text: text.slice(start), truncated: true };
|
|
46
|
+
return { text: trailingText(text, limit), truncated: true };
|
|
50
47
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// A compact execution trace: state and identity on one rail, evidence below.
|
|
2
2
|
import { hasColor, row } from "../../ui/render.js";
|
|
3
|
+
import { graphemeCeiling, graphemeFloor } from "../../text-boundary.js";
|
|
3
4
|
const OUTPUT_ROWS = 8;
|
|
4
5
|
const LIVE_OUTPUT_ROWS = 6;
|
|
5
6
|
const DIFF_ROWS = 15;
|
|
@@ -73,8 +74,12 @@ function renderDetail(detail, tone, width, pal) {
|
|
|
73
74
|
function emphasized(text, emphasis, fg) {
|
|
74
75
|
if (emphasis === undefined || emphasis.length <= 0)
|
|
75
76
|
return [{ text, fg }];
|
|
76
|
-
const
|
|
77
|
-
const
|
|
77
|
+
const requestedStart = Math.max(0, Math.min(text.length, emphasis.start));
|
|
78
|
+
const requestedEnd = Math.max(requestedStart, Math.min(text.length, requestedStart + emphasis.length));
|
|
79
|
+
const start = graphemeFloor(text, requestedStart);
|
|
80
|
+
const end = graphemeCeiling(text, requestedEnd);
|
|
81
|
+
if (start === end)
|
|
82
|
+
return [{ text, fg }];
|
|
78
83
|
return [
|
|
79
84
|
...(start === 0 ? [] : [{ text: text.slice(0, start), fg }]),
|
|
80
85
|
{ text: text.slice(start, end), fg, inverse: true },
|
package/dist/tui/turn.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// The controller speaks in stream events and tool results; the screen speaks in
|
|
4
4
|
// blocks. This is the whole of the translation, kept out of the shell so that
|
|
5
5
|
// neither has to know how the other is built.
|
|
6
|
+
import { graphemes } from "../text-boundary.js";
|
|
6
7
|
import { condense, diff } from "../ui/diff.js";
|
|
7
8
|
import { promptFor } from "./approve.js";
|
|
8
9
|
// Semantic activity labels feed the footer's compact state and timer while
|
|
@@ -57,6 +58,9 @@ export function transcribe(stage) {
|
|
|
57
58
|
onUsage(usage) {
|
|
58
59
|
stage.usage?.(usage);
|
|
59
60
|
},
|
|
61
|
+
onRequestInput(inputTokens) {
|
|
62
|
+
stage.requestInput?.(inputTokens);
|
|
63
|
+
},
|
|
60
64
|
onStatus(status) {
|
|
61
65
|
stage.status(status);
|
|
62
66
|
stage.render();
|
|
@@ -249,18 +253,35 @@ function emphasizePairs(rows) {
|
|
|
249
253
|
continue;
|
|
250
254
|
if (rows[index - 1]?.kind === "del" || rows[index + 2]?.kind === "add")
|
|
251
255
|
continue;
|
|
256
|
+
const removedClusters = graphemes(removed.text);
|
|
257
|
+
const addedClusters = graphemes(added.text);
|
|
258
|
+
let prefix = 0;
|
|
252
259
|
let start = 0;
|
|
253
|
-
while (
|
|
254
|
-
|
|
260
|
+
while (prefix < removedClusters.length &&
|
|
261
|
+
prefix < addedClusters.length &&
|
|
262
|
+
removedClusters[prefix] === addedClusters[prefix]) {
|
|
263
|
+
start += removedClusters[prefix].length;
|
|
264
|
+
prefix++;
|
|
265
|
+
}
|
|
255
266
|
let suffix = 0;
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
267
|
+
let removedSuffix = 0;
|
|
268
|
+
let addedSuffix = 0;
|
|
269
|
+
while (suffix < removedClusters.length - prefix &&
|
|
270
|
+
suffix < addedClusters.length - prefix &&
|
|
271
|
+
removedClusters[removedClusters.length - 1 - suffix] ===
|
|
272
|
+
addedClusters[addedClusters.length - 1 - suffix]) {
|
|
273
|
+
removedSuffix += removedClusters[removedClusters.length - 1 - suffix].length;
|
|
274
|
+
addedSuffix += addedClusters[addedClusters.length - 1 - suffix].length;
|
|
259
275
|
suffix++;
|
|
260
|
-
|
|
276
|
+
}
|
|
277
|
+
while (suffix > 0 &&
|
|
278
|
+
(!wordBoundary(removed.text, removedSuffix) || !wordBoundary(added.text, addedSuffix))) {
|
|
279
|
+
removedSuffix -= removedClusters[removedClusters.length - suffix].length;
|
|
280
|
+
addedSuffix -= addedClusters[addedClusters.length - suffix].length;
|
|
261
281
|
suffix--;
|
|
262
|
-
|
|
263
|
-
const
|
|
282
|
+
}
|
|
283
|
+
const removedLength = removed.text.length - start - removedSuffix;
|
|
284
|
+
const addedLength = added.text.length - start - addedSuffix;
|
|
264
285
|
if (removedLength > 0)
|
|
265
286
|
removed.emphasis = { start, length: removedLength };
|
|
266
287
|
if (addedLength > 0)
|
package/dist/ui/diff.js
CHANGED
|
@@ -8,24 +8,37 @@
|
|
|
8
8
|
* Above this many cells the table stops being worth building.
|
|
9
9
|
*
|
|
10
10
|
* The diff is quadratic in the two line counts, and it is drawn between two
|
|
11
|
-
* keystrokes.
|
|
12
|
-
*
|
|
11
|
+
* keystrokes. Common edges are removed before this limit is applied, so a
|
|
12
|
+
* local edit in a large file stays local. If the changed middle itself crosses
|
|
13
|
+
* the ceiling, the honest answer is the coarse one rather than a frame the
|
|
14
|
+
* user waits for.
|
|
13
15
|
*/
|
|
14
16
|
const CEILING = 250_000;
|
|
15
17
|
export function diff(before, after) {
|
|
16
18
|
const a = lines(before);
|
|
17
19
|
const b = lines(after);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const prefix = commonPrefix(a, b);
|
|
21
|
+
const suffix = commonSuffix(a, b, prefix);
|
|
22
|
+
const aEnd = a.length - suffix;
|
|
23
|
+
const bEnd = b.length - suffix;
|
|
24
|
+
const middleA = a.slice(prefix, aEnd);
|
|
25
|
+
const middleB = b.slice(prefix, bEnd);
|
|
26
|
+
const rows = a.slice(0, prefix).map(keep);
|
|
27
|
+
if (middleA.length * middleB.length > CEILING) {
|
|
28
|
+
for (const line of middleA)
|
|
29
|
+
rows.push(del(line));
|
|
30
|
+
for (const line of middleB)
|
|
31
|
+
rows.push(add(line));
|
|
32
|
+
appendSuffix(rows, a, aEnd);
|
|
33
|
+
return rows;
|
|
20
34
|
}
|
|
21
|
-
const table = common(
|
|
22
|
-
const rows = [];
|
|
35
|
+
const table = common(middleA, middleB);
|
|
23
36
|
let i = 0;
|
|
24
37
|
let j = 0;
|
|
25
|
-
const width =
|
|
26
|
-
while (i <
|
|
27
|
-
if (
|
|
28
|
-
rows.push(
|
|
38
|
+
const width = middleB.length + 1;
|
|
39
|
+
while (i < middleA.length && j < middleB.length) {
|
|
40
|
+
if (middleA[i] === middleB[j]) {
|
|
41
|
+
rows.push(keep(middleA[i]));
|
|
29
42
|
i++;
|
|
30
43
|
j++;
|
|
31
44
|
continue;
|
|
@@ -33,20 +46,39 @@ export function diff(before, after) {
|
|
|
33
46
|
// Deletions first on a tie, so a replaced line reads old-then-new — the
|
|
34
47
|
// order the eye expects, and the order every other diff prints.
|
|
35
48
|
if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
|
36
|
-
rows.push(del(
|
|
49
|
+
rows.push(del(middleA[i]));
|
|
37
50
|
i++;
|
|
38
51
|
}
|
|
39
52
|
else {
|
|
40
|
-
rows.push(add(
|
|
53
|
+
rows.push(add(middleB[j]));
|
|
41
54
|
j++;
|
|
42
55
|
}
|
|
43
56
|
}
|
|
44
|
-
while (i <
|
|
45
|
-
rows.push(del(
|
|
46
|
-
while (j <
|
|
47
|
-
rows.push(add(
|
|
57
|
+
while (i < middleA.length)
|
|
58
|
+
rows.push(del(middleA[i++]));
|
|
59
|
+
while (j < middleB.length)
|
|
60
|
+
rows.push(add(middleB[j++]));
|
|
61
|
+
appendSuffix(rows, a, aEnd);
|
|
48
62
|
return rows;
|
|
49
63
|
}
|
|
64
|
+
function commonPrefix(a, b) {
|
|
65
|
+
const limit = Math.min(a.length, b.length);
|
|
66
|
+
let length = 0;
|
|
67
|
+
while (length < limit && a[length] === b[length])
|
|
68
|
+
length++;
|
|
69
|
+
return length;
|
|
70
|
+
}
|
|
71
|
+
function commonSuffix(a, b, prefix) {
|
|
72
|
+
const limit = Math.min(a.length, b.length) - prefix;
|
|
73
|
+
let length = 0;
|
|
74
|
+
while (length < limit && a[a.length - length - 1] === b[b.length - length - 1])
|
|
75
|
+
length++;
|
|
76
|
+
return length;
|
|
77
|
+
}
|
|
78
|
+
function appendSuffix(rows, source, start) {
|
|
79
|
+
for (let index = start; index < source.length; index++)
|
|
80
|
+
rows.push(keep(source[index]));
|
|
81
|
+
}
|
|
50
82
|
/**
|
|
51
83
|
* Drop the unchanged stretches, keeping `context` rows either side.
|
|
52
84
|
*
|
|
@@ -109,3 +141,6 @@ function add(text) {
|
|
|
109
141
|
function del(text) {
|
|
110
142
|
return { kind: "del", text };
|
|
111
143
|
}
|
|
144
|
+
function keep(text) {
|
|
145
|
+
return { kind: "keep", text };
|
|
146
|
+
}
|
package/dist/ui/render.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Composing a terminal row out of styled segments. Pure: every function here
|
|
2
2
|
// returns a string or rows of segments, none of them write anywhere.
|
|
3
3
|
import { terminalText } from "./terminal-text.js";
|
|
4
|
-
import { elide, textWidth, wrapText } from "./width.js";
|
|
4
|
+
import { elide, splitByCells, textWidth, wrapText } from "./width.js";
|
|
5
5
|
// Built rather than written literally: a raw escape byte in the source is
|
|
6
6
|
// invisible in a diff and in a code review, which is how they survive.
|
|
7
7
|
const ESC = String.fromCharCode(27);
|
|
@@ -166,13 +166,13 @@ export function flow(segs, max, continuation = []) {
|
|
|
166
166
|
let used = 0;
|
|
167
167
|
let pending;
|
|
168
168
|
const room = () => Math.max(1, max - (rows.length === 0 ? 0 : lead));
|
|
169
|
-
const add = (tok) => {
|
|
169
|
+
const add = (tok, width = textWidth(tok.text)) => {
|
|
170
170
|
const last = current[current.length - 1];
|
|
171
171
|
if (last !== undefined && sameStyle(last, tok.seg))
|
|
172
172
|
last.text += tok.text;
|
|
173
173
|
else
|
|
174
174
|
current.push({ ...tok.seg, text: tok.text });
|
|
175
|
-
used +=
|
|
175
|
+
used += width;
|
|
176
176
|
};
|
|
177
177
|
const flush = () => {
|
|
178
178
|
rows.push(rows.length === 0 ? current : [...continuation.map(copy), ...current]);
|
|
@@ -191,39 +191,31 @@ export function flow(segs, max, continuation = []) {
|
|
|
191
191
|
continue;
|
|
192
192
|
}
|
|
193
193
|
const space = pending === undefined ? 0 : textWidth(pending.text);
|
|
194
|
-
|
|
195
|
-
if (used + space +
|
|
194
|
+
const wordWidth = textWidth(tok.text);
|
|
195
|
+
if (used + space + wordWidth > room() && current.length > 0)
|
|
196
196
|
flush();
|
|
197
197
|
else if (pending !== undefined) {
|
|
198
|
-
add(pending);
|
|
198
|
+
add(pending, space);
|
|
199
199
|
pending = undefined;
|
|
200
200
|
}
|
|
201
201
|
// A word wider than any row is spent across rows: with autowrap off, what
|
|
202
202
|
// overflows is not ugly, it is gone.
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
203
|
+
if (wordWidth > room() - used) {
|
|
204
|
+
const chunks = splitByCells(tok.text, room() - used, Math.max(1, max - lead));
|
|
205
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
206
|
+
add({ ...tok, text: chunk.text }, chunk.width);
|
|
207
|
+
if (index + 1 < chunks.length)
|
|
208
|
+
flush();
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
210
211
|
}
|
|
211
|
-
if (
|
|
212
|
-
add(
|
|
212
|
+
if (tok.text !== "")
|
|
213
|
+
add(tok, wordWidth);
|
|
213
214
|
}
|
|
214
215
|
if (current.length > 0 || rows.length === 0)
|
|
215
216
|
flush();
|
|
216
217
|
return rows;
|
|
217
218
|
}
|
|
218
|
-
function clipTo(text, cols) {
|
|
219
|
-
let out = "";
|
|
220
|
-
for (const char of text) {
|
|
221
|
-
if (textWidth(out + char) > cols)
|
|
222
|
-
break;
|
|
223
|
-
out += char;
|
|
224
|
-
}
|
|
225
|
-
return out;
|
|
226
|
-
}
|
|
227
219
|
function copy(seg) {
|
|
228
220
|
return { ...seg };
|
|
229
221
|
}
|
package/dist/ui/width.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// cell at all. Every alignment in the UI depends on this file being right:
|
|
7
7
|
// the right-hand column, a ground band, the cursor. Nothing measures with
|
|
8
8
|
// `.length`.
|
|
9
|
+
import { graphemes, segmentGraphemes } from "../text-boundary.js";
|
|
10
|
+
export { graphemes } from "../text-boundary.js";
|
|
9
11
|
/** Ranges the terminal draws two cells wide (East Asian Wide and Fullwidth). */
|
|
10
12
|
const WIDE = [
|
|
11
13
|
[0x1100, 0x115f],
|
|
@@ -93,16 +95,6 @@ function inRanges(code, ranges) {
|
|
|
93
95
|
// in source is a byte nobody reviews.
|
|
94
96
|
const VS15 = String.fromCodePoint(0xfe0e);
|
|
95
97
|
const VS16 = String.fromCodePoint(0xfe0f);
|
|
96
|
-
// Grapheme segmentation is in the standard library, so a family emoji built
|
|
97
|
-
// out of five code points and three joiners counts as the one thing the
|
|
98
|
-
// terminal actually draws.
|
|
99
|
-
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
100
|
-
export function graphemes(text) {
|
|
101
|
-
const out = [];
|
|
102
|
-
for (const { segment } of SEGMENTER.segment(text))
|
|
103
|
-
out.push(segment);
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
98
|
/**
|
|
107
99
|
* Cells taken by one grapheme cluster.
|
|
108
100
|
*
|
|
@@ -126,17 +118,38 @@ export function charWidth(cluster) {
|
|
|
126
118
|
}
|
|
127
119
|
export function textWidth(text) {
|
|
128
120
|
let total = 0;
|
|
129
|
-
for (const cluster of
|
|
121
|
+
for (const cluster of segmentGraphemes(text))
|
|
130
122
|
total += charWidth(cluster.segment);
|
|
131
123
|
return total;
|
|
132
124
|
}
|
|
125
|
+
/** Split text into grapheme-safe chunks without rescanning any suffix. */
|
|
126
|
+
export function splitByCells(text, firstCols, followingCols = firstCols) {
|
|
127
|
+
if (text === "")
|
|
128
|
+
return [{ text, width: 0 }];
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let start = 0;
|
|
131
|
+
let used = 0;
|
|
132
|
+
let room = Math.max(1, firstCols);
|
|
133
|
+
for (const { index, segment } of segmentGraphemes(text)) {
|
|
134
|
+
const width = charWidth(segment);
|
|
135
|
+
if (index > start && used + width > room) {
|
|
136
|
+
chunks.push({ text: text.slice(start, index), width: used });
|
|
137
|
+
start = index;
|
|
138
|
+
used = 0;
|
|
139
|
+
room = Math.max(1, followingCols);
|
|
140
|
+
}
|
|
141
|
+
used += width;
|
|
142
|
+
}
|
|
143
|
+
chunks.push({ text: text.slice(start), width: used });
|
|
144
|
+
return chunks;
|
|
145
|
+
}
|
|
133
146
|
/** The longest prefix of `text` that fits in `cols` cells. */
|
|
134
147
|
export function clip(text, cols) {
|
|
135
148
|
if (cols <= 0)
|
|
136
149
|
return "";
|
|
137
150
|
let out = "";
|
|
138
151
|
let used = 0;
|
|
139
|
-
for (const { segment } of
|
|
152
|
+
for (const { segment } of segmentGraphemes(text)) {
|
|
140
153
|
const w = charWidth(segment);
|
|
141
154
|
if (used + w > cols)
|
|
142
155
|
break;
|
|
@@ -214,17 +227,13 @@ export function wrapText(text, max, continuation = "") {
|
|
|
214
227
|
flush();
|
|
215
228
|
if (w > room()) {
|
|
216
229
|
// Too long for any row: spend whole rows on it until it fits.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
rest = rest.slice(head.length);
|
|
224
|
-
flush();
|
|
230
|
+
const chunks = splitByCells(word, room(), Math.max(1, max - lead));
|
|
231
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
232
|
+
line = chunk.text;
|
|
233
|
+
width = chunk.width;
|
|
234
|
+
if (index + 1 < chunks.length)
|
|
235
|
+
flush();
|
|
225
236
|
}
|
|
226
|
-
line = rest;
|
|
227
|
-
width = textWidth(rest);
|
|
228
237
|
continue;
|
|
229
238
|
}
|
|
230
239
|
line = line === "" ? word : `${line} ${word}`;
|
package/dist/usage.js
CHANGED
|
@@ -12,9 +12,12 @@ export function emptyUsage() {
|
|
|
12
12
|
}
|
|
13
13
|
export function recordUsage(total, next) {
|
|
14
14
|
total.requests += 1;
|
|
15
|
-
total.lastInputTokens = next.inputTokens;
|
|
16
15
|
addUsage(total, next);
|
|
17
16
|
}
|
|
17
|
+
/** Replace context pressure without inventing provider-reported usage. */
|
|
18
|
+
export function recordRequestInput(total, inputTokens) {
|
|
19
|
+
total.lastInputTokens = inputTokens;
|
|
20
|
+
}
|
|
18
21
|
/** Account for an internal request without replacing the main context signal. */
|
|
19
22
|
export function recordAuxiliaryUsage(total, next) {
|
|
20
23
|
total.requests += 1;
|
|
@@ -32,6 +35,7 @@ export function usageFromHistory(messages) {
|
|
|
32
35
|
for (const message of messages) {
|
|
33
36
|
if (message.role === "assistant" && message.usage !== undefined) {
|
|
34
37
|
recordUsage(total, message.usage);
|
|
38
|
+
recordRequestInput(total, message.usage.inputTokens);
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
41
|
return total;
|