@giovannijecha/jecode 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -29
- package/dist/atomic.js +14 -0
- package/dist/batch.js +5 -2
- package/dist/cli-info.js +1 -1
- package/dist/config.js +4 -1
- package/dist/context/policy.js +14 -7
- package/dist/controller-request.js +1 -1
- package/dist/controller.js +39 -15
- package/dist/duration.js +8 -0
- package/dist/oauth-http.js +2 -1
- package/dist/openai-account.js +12 -8
- package/dist/openai-oauth-callback.js +2 -1
- package/dist/providers/anthropic-stream.js +8 -0
- package/dist/providers/anthropic-wire.js +22 -10
- package/dist/providers/http.js +4 -3
- package/dist/providers/ollama-stream.js +7 -0
- package/dist/providers/ollama-wire.js +11 -3
- package/dist/providers/openai-codex.js +0 -2
- package/dist/providers/openai-stream.js +57 -2
- package/dist/providers/openai-wire.js +27 -12
- package/dist/providers/sse.js +115 -38
- package/dist/sessions/codec.js +13 -5
- package/dist/sessions/store.js +2 -1
- package/dist/settings-command.js +0 -5
- package/dist/settings.js +0 -2
- package/dist/steering.js +58 -0
- package/dist/text-boundary.js +47 -0
- package/dist/timeline.js +2 -1
- package/dist/tools/args.js +1 -1
- package/dist/tools/fs.js +5 -0
- package/dist/tools/text-boundary.js +1 -31
- package/dist/transcript.js +6 -1
- package/dist/tui/activity.js +20 -5
- package/dist/tui/app-input.js +31 -6
- package/dist/tui/app-workflows.js +57 -14
- package/dist/tui/app.js +18 -3
- package/dist/tui/blocks.js +7 -2
- package/dist/tui/components/messages.js +20 -17
- package/dist/tui/components/misc.js +7 -6
- package/dist/tui/components/status.js +12 -1
- package/dist/tui/components/tool.js +152 -50
- package/dist/tui/help.js +1 -1
- package/dist/tui/motion.js +32 -0
- package/dist/tui/session-view.js +3 -2
- package/dist/tui/transcript-grammar.js +25 -0
- package/dist/tui/transcript-view.js +132 -7
- package/dist/tui/turn.js +78 -39
- package/dist/tui/view.js +4 -2
- package/dist/ui/diff.js +51 -16
- package/dist/ui/render.js +17 -27
- package/dist/ui/theme.js +19 -18
- package/dist/ui/width.js +31 -22
- package/dist/usage.js +5 -1
- package/package.json +1 -1
|
@@ -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/args.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// tool takes three arguments and this is the whole job.
|
|
3
3
|
//
|
|
4
4
|
// Every throw here becomes an is_error tool result the model can read and
|
|
5
|
-
// correct on the next
|
|
5
|
+
// correct on the next request, so the messages are written for that reader.
|
|
6
6
|
export function requireString(args, name, allowEmpty = false) {
|
|
7
7
|
const value = args[name];
|
|
8
8
|
if (typeof value !== "string" || (!allowEmpty && value === "")) {
|
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";
|
package/dist/transcript.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// A portable transcript: screen blocks in, Markdown out.
|
|
2
2
|
import { terminalText } from "./ui/terminal-text.js";
|
|
3
|
+
import { toolDuration } from "./duration.js";
|
|
3
4
|
export function defaultTranscriptName(now = new Date()) {
|
|
4
5
|
return `jecode-transcript-${now.toISOString().replace(/[-:.]/g, "")}.md`;
|
|
5
6
|
}
|
|
@@ -17,7 +18,11 @@ export function transcriptMarkdown(blocks) {
|
|
|
17
18
|
out.push("<details>", "<summary>Reasoning</summary>", "", safeMultiline(block.text), "", "</details>", "");
|
|
18
19
|
break;
|
|
19
20
|
case "tool":
|
|
20
|
-
|
|
21
|
+
const outcome = [
|
|
22
|
+
block.right === "" ? undefined : safeInline(block.right),
|
|
23
|
+
block.durationMs === undefined ? undefined : toolDuration(block.durationMs),
|
|
24
|
+
].filter((part) => part !== undefined).join(" · ");
|
|
25
|
+
out.push(`- **${safeInline(block.name)}**${block.target === "" ? "" : ` \`${inlineCode(block.target)}\``}${outcome === "" ? "" : ` — ${outcome}`}`);
|
|
21
26
|
if ((block.body?.length ?? 0) > 0) {
|
|
22
27
|
out.push("", "````text", ...(block.body ?? []).map(detail), "````", "");
|
|
23
28
|
}
|
package/dist/tui/activity.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
// One foreground operation owns cancellation and elapsed time.
|
|
2
2
|
export function begin(kind, label, now = Date.now()) {
|
|
3
|
-
return {
|
|
3
|
+
return {
|
|
4
|
+
kind,
|
|
5
|
+
label,
|
|
6
|
+
control: new AbortController(),
|
|
7
|
+
startedAt: now,
|
|
8
|
+
phase: { label, startedAt: now },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Change the visible phase without restarting its timer on repeated stream chunks. */
|
|
12
|
+
export function transition(activity, label, now = Date.now()) {
|
|
13
|
+
if (activity.phase.label === label)
|
|
14
|
+
return;
|
|
15
|
+
activity.phase = { label, startedAt: now };
|
|
4
16
|
}
|
|
5
17
|
export function elapsed(activity, now = Date.now()) {
|
|
6
|
-
|
|
18
|
+
return since(activity.startedAt, now);
|
|
19
|
+
}
|
|
20
|
+
export function activityStatus(activity, now = Date.now()) {
|
|
21
|
+
return `${activity.phase.label} · ${since(activity.phase.startedAt, now)}`;
|
|
22
|
+
}
|
|
23
|
+
function since(startedAt, now) {
|
|
24
|
+
const seconds = Math.max(0, Math.floor((now - startedAt) / 1_000));
|
|
7
25
|
if (seconds < 60)
|
|
8
26
|
return `${seconds}s`;
|
|
9
27
|
const minutes = Math.floor(seconds / 60);
|
|
10
28
|
return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
11
29
|
}
|
|
12
|
-
export function activityStatus(activity, label = activity.label, now = Date.now()) {
|
|
13
|
-
return `${label} · ${elapsed(activity, now)}`;
|
|
14
|
-
}
|
package/dist/tui/app-input.js
CHANGED
|
@@ -69,6 +69,8 @@ export function appInput(options) {
|
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
case "tab": {
|
|
72
|
+
if (state.activity !== undefined)
|
|
73
|
+
return;
|
|
72
74
|
const completion = state.completing ?? activateCompletion(state.editor.text);
|
|
73
75
|
const completed = completion === undefined ? undefined : selectedCompletion(completion);
|
|
74
76
|
if (completed !== undefined) {
|
|
@@ -107,7 +109,7 @@ export function appInput(options) {
|
|
|
107
109
|
const edited = applyKey(state.editor, key);
|
|
108
110
|
if (edited !== undefined) {
|
|
109
111
|
state.editor = edited;
|
|
110
|
-
state.completing = activateCompletion(edited.text);
|
|
112
|
+
state.completing = state.activity === undefined ? activateCompletion(edited.text) : undefined;
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
function recall(step) {
|
|
@@ -131,9 +133,26 @@ export function appInput(options) {
|
|
|
131
133
|
}
|
|
132
134
|
function submit() {
|
|
133
135
|
const text = state.editor.text.trim();
|
|
134
|
-
if (text === ""
|
|
136
|
+
if (text === "")
|
|
135
137
|
return;
|
|
136
138
|
const isCommand = text.startsWith("/");
|
|
139
|
+
if (state.activity !== undefined) {
|
|
140
|
+
if (isCommand) {
|
|
141
|
+
keep("Slash commands run after the active work finishes");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const result = actions.steer(text);
|
|
145
|
+
if (result !== "queued") {
|
|
146
|
+
keep(result === "full"
|
|
147
|
+
? "Steering queue is full"
|
|
148
|
+
: result === "closed"
|
|
149
|
+
? "The turn is finishing"
|
|
150
|
+
: "Wait for the active command to finish");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
accept(text);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
137
156
|
if (!isCommand) {
|
|
138
157
|
const blocker = turnBlocker(session);
|
|
139
158
|
if (blocker !== undefined) {
|
|
@@ -141,6 +160,13 @@ export function appInput(options) {
|
|
|
141
160
|
return;
|
|
142
161
|
}
|
|
143
162
|
}
|
|
163
|
+
accept(text);
|
|
164
|
+
if (isCommand)
|
|
165
|
+
void actions.command(text);
|
|
166
|
+
else
|
|
167
|
+
void actions.turn(text);
|
|
168
|
+
}
|
|
169
|
+
function accept(text) {
|
|
144
170
|
state.editor = edit.EMPTY;
|
|
145
171
|
state.recall = -1;
|
|
146
172
|
state.draft = "";
|
|
@@ -149,10 +175,9 @@ export function appInput(options) {
|
|
|
149
175
|
state.scroll = 0;
|
|
150
176
|
state.follow = true;
|
|
151
177
|
state.unseen = 0;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
void actions.turn(text);
|
|
178
|
+
}
|
|
179
|
+
function keep(text) {
|
|
180
|
+
feedback.show({ text: `${text} · prompt kept`, tone: "warn", timeoutMs: 4_000 });
|
|
156
181
|
}
|
|
157
182
|
return { handle };
|
|
158
183
|
}
|
|
@@ -6,9 +6,11 @@ import { compactContext } from "../context/compactor.js";
|
|
|
6
6
|
import { compactSession } from "../context/manual.js";
|
|
7
7
|
import { isContextOverflow } from "../context/policy.js";
|
|
8
8
|
import { updateSettings } from "../settings.js";
|
|
9
|
+
import { steeringInbox } from "../steering.js";
|
|
9
10
|
import { saveTranscript } from "../transcript-export.js";
|
|
10
|
-
import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
|
|
11
|
+
import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "../usage.js";
|
|
11
12
|
import { selectTimeline } from "../timeline.js";
|
|
13
|
+
import { transition } from "./activity.js";
|
|
12
14
|
import { answerAt } from "./approve.js";
|
|
13
15
|
import * as edit from "./editor.js";
|
|
14
16
|
import { cancel as cancelOpen } from "./overlay.js";
|
|
@@ -17,6 +19,7 @@ import { transcribe } from "./turn.js";
|
|
|
17
19
|
const WAITING = "Waiting";
|
|
18
20
|
export function appWorkflows(options) {
|
|
19
21
|
const { session, state, permissions, feedback } = options;
|
|
22
|
+
let activeSteering;
|
|
20
23
|
const choose = (picker) => new Promise((resolve) => {
|
|
21
24
|
state.open = { picker, settle: resolve };
|
|
22
25
|
options.render();
|
|
@@ -25,6 +28,7 @@ export function appWorkflows(options) {
|
|
|
25
28
|
const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
|
|
26
29
|
if (activity === undefined)
|
|
27
30
|
return;
|
|
31
|
+
const status = (label) => transition(activity, label);
|
|
28
32
|
try {
|
|
29
33
|
const outcome = await handleCommand(text, session, {
|
|
30
34
|
emit: options.commandNotice,
|
|
@@ -43,7 +47,7 @@ export function appWorkflows(options) {
|
|
|
43
47
|
options.render();
|
|
44
48
|
}),
|
|
45
49
|
status: (said) => {
|
|
46
|
-
|
|
50
|
+
status(said ?? activity.label);
|
|
47
51
|
options.render();
|
|
48
52
|
},
|
|
49
53
|
reset: async () => {
|
|
@@ -78,8 +82,8 @@ export function appWorkflows(options) {
|
|
|
78
82
|
}
|
|
79
83
|
return compactSession(session, {
|
|
80
84
|
signal: activity.control.signal,
|
|
81
|
-
onStatus: (
|
|
82
|
-
|
|
85
|
+
onStatus: (said) => {
|
|
86
|
+
status(said ?? activity.label);
|
|
83
87
|
options.render();
|
|
84
88
|
},
|
|
85
89
|
});
|
|
@@ -108,6 +112,15 @@ export function appWorkflows(options) {
|
|
|
108
112
|
const activity = options.startActivity("turn", WAITING);
|
|
109
113
|
if (activity === undefined)
|
|
110
114
|
return;
|
|
115
|
+
const inbox = steeringInbox((pending, accepting) => {
|
|
116
|
+
if (activeSteering !== inbox)
|
|
117
|
+
return;
|
|
118
|
+
state.steering = accepting ? pending : undefined;
|
|
119
|
+
options.render();
|
|
120
|
+
});
|
|
121
|
+
activeSteering = inbox;
|
|
122
|
+
state.steering = 0;
|
|
123
|
+
const status = (label) => transition(activity, label);
|
|
111
124
|
const parentId = session.conversation.activeNodeId;
|
|
112
125
|
const history = session.conversation.history;
|
|
113
126
|
const modelHistory = session.conversation.contextHistory;
|
|
@@ -117,12 +130,13 @@ export function appWorkflows(options) {
|
|
|
117
130
|
const prospectiveNodeId = session.conversation.nodes.length + 1;
|
|
118
131
|
let nodeId;
|
|
119
132
|
let context;
|
|
133
|
+
const unpersistedSteering = [];
|
|
120
134
|
let firstPolicy = true;
|
|
121
135
|
const policy = () => {
|
|
122
136
|
let visible = firstPolicy;
|
|
123
137
|
firstPolicy = false;
|
|
124
138
|
if (visible) {
|
|
125
|
-
|
|
139
|
+
status("Checking context");
|
|
126
140
|
options.render();
|
|
127
141
|
}
|
|
128
142
|
return resolveContextPolicy({
|
|
@@ -130,14 +144,14 @@ export function appWorkflows(options) {
|
|
|
130
144
|
model: session.model,
|
|
131
145
|
compactionPercent: session.config.compactionPercent,
|
|
132
146
|
signal: activity.control.signal,
|
|
133
|
-
onStatus: (
|
|
147
|
+
onStatus: (said) => {
|
|
134
148
|
visible = true;
|
|
135
|
-
|
|
149
|
+
status(said);
|
|
136
150
|
options.render();
|
|
137
151
|
},
|
|
138
152
|
}).finally(() => {
|
|
139
153
|
if (visible) {
|
|
140
|
-
|
|
154
|
+
status(WAITING);
|
|
141
155
|
options.render();
|
|
142
156
|
}
|
|
143
157
|
});
|
|
@@ -157,9 +171,10 @@ export function appWorkflows(options) {
|
|
|
157
171
|
options.render();
|
|
158
172
|
},
|
|
159
173
|
status: (text) => {
|
|
160
|
-
|
|
174
|
+
status(text);
|
|
161
175
|
},
|
|
162
176
|
usage: (usage) => recordUsage(session.usage, usage),
|
|
177
|
+
requestInput: (inputTokens) => recordRequestInput(session.usage, inputTokens),
|
|
163
178
|
});
|
|
164
179
|
const persist = async (checkpoint, settlement, failure) => {
|
|
165
180
|
const next = session.conversation.commit({
|
|
@@ -180,6 +195,7 @@ export function appWorkflows(options) {
|
|
|
180
195
|
session.conversation = next;
|
|
181
196
|
nodeId = next.activeNodeId;
|
|
182
197
|
state.committedNodeId = next.activeNodeId;
|
|
198
|
+
unpersistedSteering.length = 0;
|
|
183
199
|
};
|
|
184
200
|
const compact = async (checkpoint, projected, request) => {
|
|
185
201
|
if (request.reason === "overflow" &&
|
|
@@ -200,11 +216,11 @@ export function appWorkflows(options) {
|
|
|
200
216
|
force,
|
|
201
217
|
policy: request.policy,
|
|
202
218
|
onBegin: () => {
|
|
203
|
-
|
|
219
|
+
status("Compacting");
|
|
204
220
|
options.render();
|
|
205
221
|
},
|
|
206
222
|
onEnd: () => {
|
|
207
|
-
|
|
223
|
+
status(WAITING);
|
|
208
224
|
options.render();
|
|
209
225
|
},
|
|
210
226
|
});
|
|
@@ -216,6 +232,11 @@ export function appWorkflows(options) {
|
|
|
216
232
|
return result.messages;
|
|
217
233
|
};
|
|
218
234
|
events.onContext = compact;
|
|
235
|
+
events.onSteering = (guidance) => {
|
|
236
|
+
unpersistedSteering.push(guidance);
|
|
237
|
+
options.emit({ kind: "user", text: guidance });
|
|
238
|
+
options.render();
|
|
239
|
+
};
|
|
219
240
|
events.onCheckpoint = async (checkpoint, settlement, projected) => {
|
|
220
241
|
await persist(checkpoint, settlement);
|
|
221
242
|
const compacted = await compact(checkpoint, projected, {
|
|
@@ -230,7 +251,7 @@ export function appWorkflows(options) {
|
|
|
230
251
|
let finishReason;
|
|
231
252
|
let failed;
|
|
232
253
|
try {
|
|
233
|
-
await runTurn(history, controllerOptions(session, policy, permissions.availableTools()), events, activity.control.signal, modelHistory);
|
|
254
|
+
await runTurn(history, controllerOptions(session, policy, permissions.availableTools(), inbox), events, activity.control.signal, modelHistory);
|
|
234
255
|
}
|
|
235
256
|
catch (error) {
|
|
236
257
|
const interrupted = activity.control.signal.aborted;
|
|
@@ -246,6 +267,10 @@ export function appWorkflows(options) {
|
|
|
246
267
|
}
|
|
247
268
|
}
|
|
248
269
|
finally {
|
|
270
|
+
let pendingSteering = inbox.close();
|
|
271
|
+
if (activeSteering === inbox)
|
|
272
|
+
activeSteering = undefined;
|
|
273
|
+
state.steering = undefined;
|
|
249
274
|
try {
|
|
250
275
|
events.finish(finishReason);
|
|
251
276
|
if (failed !== undefined) {
|
|
@@ -264,7 +289,14 @@ export function appWorkflows(options) {
|
|
|
264
289
|
// been saved. Revert to the last durable path and return the input
|
|
265
290
|
// to the composer so the user can retry without losing it.
|
|
266
291
|
options.replaceTranscript();
|
|
267
|
-
state.editor = edit.of(
|
|
292
|
+
state.editor = edit.of([
|
|
293
|
+
...(nodeId === undefined ? [text] : []),
|
|
294
|
+
...unpersistedSteering,
|
|
295
|
+
...pendingSteering,
|
|
296
|
+
state.editor.text,
|
|
297
|
+
].filter((part) => part !== "").join("\n\n"));
|
|
298
|
+
state.completing = undefined;
|
|
299
|
+
pendingSteering = [];
|
|
268
300
|
feedback.show({
|
|
269
301
|
text: error.message,
|
|
270
302
|
tone: "error",
|
|
@@ -274,11 +306,22 @@ export function appWorkflows(options) {
|
|
|
274
306
|
}
|
|
275
307
|
}
|
|
276
308
|
finally {
|
|
309
|
+
restorePendingSteering(state, pendingSteering);
|
|
277
310
|
options.finishActivity(activity);
|
|
278
311
|
}
|
|
279
312
|
}
|
|
280
313
|
}
|
|
281
|
-
|
|
314
|
+
function steer(text) {
|
|
315
|
+
return activeSteering?.offer(text) ?? "unavailable";
|
|
316
|
+
}
|
|
317
|
+
return { command, turn, steer };
|
|
318
|
+
}
|
|
319
|
+
function restorePendingSteering(state, messages) {
|
|
320
|
+
if (messages.length === 0)
|
|
321
|
+
return;
|
|
322
|
+
const pending = messages.join("\n\n");
|
|
323
|
+
state.editor = edit.of(state.editor.text === "" ? pending : `${pending}\n\n${state.editor.text}`);
|
|
324
|
+
state.completing = undefined;
|
|
282
325
|
}
|
|
283
326
|
function closeFailedTurn(history, settlement) {
|
|
284
327
|
const closed = [...history];
|
package/dist/tui/app.js
CHANGED
|
@@ -21,6 +21,7 @@ import { appWorkflows } from "./app-workflows.js";
|
|
|
21
21
|
import { sessionPermissions } from "../permissions.js";
|
|
22
22
|
import { resumePicker } from "./resume.js";
|
|
23
23
|
const FRAME_MS = 16;
|
|
24
|
+
const MOTION_FRAME_MS = 40;
|
|
24
25
|
const ACTIVITY_REFRESH_MS = 1_000;
|
|
25
26
|
/** How long a lone escape waits to prove it is not the start of a sequence. */
|
|
26
27
|
const ESCAPE_MS = 25;
|
|
@@ -36,6 +37,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
36
37
|
const permissions = sessionPermissions(session.tools, session.config.autoApprove);
|
|
37
38
|
let closed;
|
|
38
39
|
let frameTimer;
|
|
40
|
+
let motionTimer;
|
|
39
41
|
let activityTimer;
|
|
40
42
|
let escapeTimer;
|
|
41
43
|
let stopResize = () => { };
|
|
@@ -67,10 +69,12 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
67
69
|
footer: footerInfo(session, workspace),
|
|
68
70
|
status: state.activity === undefined
|
|
69
71
|
? undefined
|
|
70
|
-
: activityStatus(state.activity,
|
|
72
|
+
: activityStatus(state.activity, now),
|
|
73
|
+
steering: state.steering,
|
|
71
74
|
feedback: state.feedback,
|
|
72
75
|
readiness: turnBlocker(session),
|
|
73
76
|
now,
|
|
77
|
+
reducedMotion: session.config.reducedMotion,
|
|
74
78
|
modal: overlay.shown(state.open),
|
|
75
79
|
menu: completionOptions(state.completing),
|
|
76
80
|
menuIndex: state.completing?.index,
|
|
@@ -98,6 +102,16 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
98
102
|
paint.paint(frame.rows, frame.cursor);
|
|
99
103
|
if (frame.transcriptPending)
|
|
100
104
|
render();
|
|
105
|
+
if (frame.transcriptAnimating && motionTimer === undefined) {
|
|
106
|
+
motionTimer = setTimeout(() => guard(() => {
|
|
107
|
+
motionTimer = undefined;
|
|
108
|
+
render();
|
|
109
|
+
}), Math.max(0, MOTION_FRAME_MS - FRAME_MS));
|
|
110
|
+
}
|
|
111
|
+
else if (!frame.transcriptAnimating && motionTimer !== undefined) {
|
|
112
|
+
clearTimeout(motionTimer);
|
|
113
|
+
motionTimer = undefined;
|
|
114
|
+
}
|
|
101
115
|
};
|
|
102
116
|
// Streaming produces a token at a time; painting at that rate is wasted
|
|
103
117
|
// work, so repaints coalesce onto one frame.
|
|
@@ -148,6 +162,8 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
148
162
|
clearInterval(activityTimer);
|
|
149
163
|
if (frameTimer !== undefined)
|
|
150
164
|
clearTimeout(frameTimer);
|
|
165
|
+
if (motionTimer !== undefined)
|
|
166
|
+
clearTimeout(motionTimer);
|
|
151
167
|
if (escapeTimer !== undefined)
|
|
152
168
|
clearTimeout(escapeTimer);
|
|
153
169
|
safely(() => feedback.close());
|
|
@@ -193,7 +209,6 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
193
209
|
return undefined;
|
|
194
210
|
const activity = begin(kind, label);
|
|
195
211
|
state.activity = activity;
|
|
196
|
-
state.status = label;
|
|
197
212
|
activityTimer = setInterval(() => guard(() => {
|
|
198
213
|
let activeTool;
|
|
199
214
|
for (let index = state.blocks.length - 1; index >= 0; index--) {
|
|
@@ -215,7 +230,6 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
215
230
|
clearInterval(activityTimer);
|
|
216
231
|
activityTimer = undefined;
|
|
217
232
|
state.activity = undefined;
|
|
218
|
-
state.status = undefined;
|
|
219
233
|
state.open = overlay.cancel(state.open);
|
|
220
234
|
if (state.closeWhenIdle)
|
|
221
235
|
quit();
|
|
@@ -248,6 +262,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
248
262
|
actions: {
|
|
249
263
|
command: (text) => track(actions.command(text)),
|
|
250
264
|
turn: (text) => track(actions.turn(text)),
|
|
265
|
+
steer: (text) => actions.steer(text),
|
|
251
266
|
},
|
|
252
267
|
live: () => live,
|
|
253
268
|
quit,
|
package/dist/tui/blocks.js
CHANGED
|
@@ -9,11 +9,16 @@ export function render(block, width, pal, context = {}) {
|
|
|
9
9
|
case "answer":
|
|
10
10
|
return renderAnswer(block, width, pal);
|
|
11
11
|
case "reasoning":
|
|
12
|
-
return renderReasoning(block, width, pal
|
|
12
|
+
return renderReasoning(block, width, pal, {
|
|
13
|
+
continues: context.previous?.kind === "tool" || context.previous?.kind === "reasoning",
|
|
14
|
+
});
|
|
13
15
|
case "tool":
|
|
14
16
|
return renderTool(block, width, pal, {
|
|
15
|
-
continues: context.previous?.kind === "tool",
|
|
17
|
+
continues: context.previous?.kind === "tool" || context.previous?.kind === "reasoning",
|
|
18
|
+
followsReasoning: context.previous?.kind === "reasoning",
|
|
16
19
|
now: context.now,
|
|
20
|
+
motion: context.motion,
|
|
21
|
+
reducedMotion: context.reducedMotion,
|
|
17
22
|
});
|
|
18
23
|
case "notice":
|
|
19
24
|
return renderNotice(block, width, pal);
|
|
@@ -1,28 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { trailingText } from "../../text-boundary.js";
|
|
2
|
+
import { row } from "../../ui/render.js";
|
|
2
3
|
import { markdown } from "../../ui/markdown.js";
|
|
3
|
-
|
|
4
|
+
import { transcriptLead, transcriptWidth } from "../transcript-grammar.js";
|
|
4
5
|
export const REASONING_PREVIEW_ROWS = 3;
|
|
5
6
|
const MIN_REASONING_PREVIEW_CHARS = 4_096;
|
|
6
7
|
const REASONING_PREVIEW_OVERSCAN = 12;
|
|
7
8
|
export function renderUser(block, width, pal) {
|
|
8
|
-
const inner =
|
|
9
|
+
const inner = transcriptWidth(width);
|
|
9
10
|
const content = markdown(block.text, inner, pal, inner);
|
|
10
11
|
return [
|
|
11
12
|
"",
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
...content.map((line, index) => row(width, [
|
|
14
|
+
...transcriptLead(width, index === 0
|
|
15
|
+
? { text: "❯", fg: pal.accent, bold: true }
|
|
16
|
+
: undefined),
|
|
17
|
+
...line.segs,
|
|
18
|
+
])),
|
|
15
19
|
];
|
|
16
20
|
}
|
|
17
21
|
export function renderAnswer(block, width, pal) {
|
|
18
|
-
const inner =
|
|
22
|
+
const inner = transcriptWidth(width);
|
|
19
23
|
return [
|
|
20
24
|
"",
|
|
21
|
-
...markdown(block.text, inner, pal, inner).map((line) => row(width, line.segs
|
|
25
|
+
...markdown(block.text, inner, pal, inner).map((line) => row(width, [...transcriptLead(width), ...line.segs])),
|
|
22
26
|
];
|
|
23
27
|
}
|
|
24
|
-
export function renderReasoning(block, width, pal) {
|
|
25
|
-
const inner =
|
|
28
|
+
export function renderReasoning(block, width, pal, context = {}) {
|
|
29
|
+
const inner = transcriptWidth(width);
|
|
26
30
|
// Expanding a live stream is deferred until it is sealed. Re-parsing an
|
|
27
31
|
// ever-growing full thought on every token makes the whole TUI stall.
|
|
28
32
|
const expanded = block.expanded === true && block.live !== true;
|
|
@@ -32,8 +36,11 @@ export function renderReasoning(block, width, pal) {
|
|
|
32
36
|
const content = markdown(source.text, inner, pal, inner);
|
|
33
37
|
const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
|
|
34
38
|
return [
|
|
35
|
-
"",
|
|
36
|
-
...visible.map((line) => row(width,
|
|
39
|
+
...(context.continues === true ? [] : [""]),
|
|
40
|
+
...visible.map((line) => row(width, [
|
|
41
|
+
...transcriptLead(width, { text: "│", fg: pal.rule }),
|
|
42
|
+
...line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })),
|
|
43
|
+
])),
|
|
37
44
|
];
|
|
38
45
|
}
|
|
39
46
|
export function reasoningPreviewSource(text, width) {
|
|
@@ -42,9 +49,5 @@ export function reasoningPreviewSource(text, width) {
|
|
|
42
49
|
return { text, truncated: false };
|
|
43
50
|
// A compact view only needs its visible tail. The complete text remains on
|
|
44
51
|
// 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 };
|
|
52
|
+
return { text: trailingText(text, limit), truncated: true };
|
|
50
53
|
}
|