@giovannijecha/jecode 0.3.2 → 0.5.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 +63 -23
- package/dist/batch.js +22 -2
- package/dist/cli-info.js +3 -0
- package/dist/command-settings.js +19 -0
- package/dist/commands.js +11 -14
- package/dist/config.js +2 -0
- package/dist/controller.js +3 -0
- package/dist/conversation.js +208 -0
- package/dist/credential-commands.js +63 -80
- package/dist/credentials.js +7 -1
- package/dist/launch.js +19 -0
- package/dist/model-command.js +171 -0
- package/dist/permission-command.js +52 -53
- package/dist/provider-commands.js +71 -228
- package/dist/provider-errors.js +4 -3
- package/dist/provider-label.js +2 -2
- package/dist/providers/ollama.js +8 -3
- package/dist/sessions/codec.js +344 -0
- package/dist/sessions/lease.js +76 -0
- package/dist/sessions/runtime.js +73 -0
- package/dist/sessions/store.js +368 -0
- package/dist/settings-command.js +43 -98
- package/dist/start.js +67 -4
- package/dist/transcript-types.js +6 -0
- package/dist/tui/activity.js +3 -0
- package/dist/tui/app-input.js +9 -6
- package/dist/tui/app-workflows.js +37 -8
- package/dist/tui/app.js +72 -20
- package/dist/tui/components/composer.js +1 -1
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/components/menu.js +27 -15
- package/dist/tui/components/messages.js +1 -15
- package/dist/tui/components/prompt.js +2 -2
- package/dist/tui/components/status.js +5 -2
- package/dist/tui/components/tool.js +22 -5
- package/dist/tui/editor.js +54 -7
- package/dist/tui/feedback.js +5 -2
- package/dist/tui/field.js +1 -1
- package/dist/tui/help.js +4 -1
- package/dist/tui/input.js +4 -0
- package/dist/tui/keys.js +14 -3
- package/dist/tui/overlay.js +6 -0
- package/dist/tui/picker.js +26 -10
- package/dist/tui/resume.js +24 -0
- package/dist/tui/session-view.js +2 -2
- package/dist/tui/turn.js +2 -3
- package/dist/tui/view.js +1 -1
- package/dist/ui/diff.js +2 -0
- package/dist/ui/inline.js +2 -2
- package/dist/ui/markdown.js +7 -7
- package/dist/ui/render.js +2 -0
- package/dist/ui/theme.js +4 -4
- package/dist/usage.js +9 -0
- package/package.json +1 -1
package/dist/start.js
CHANGED
|
@@ -3,12 +3,16 @@ import * as path from "node:path";
|
|
|
3
3
|
import { runBatch } from "./batch.js";
|
|
4
4
|
import { showCliInfo } from "./cli-info.js";
|
|
5
5
|
import { loadConfig } from "./config.js";
|
|
6
|
+
import { ConversationTree } from "./conversation.js";
|
|
7
|
+
import { parseLaunch } from "./launch.js";
|
|
6
8
|
import { systemPrompt } from "./prompt.js";
|
|
7
9
|
import { configureProviders, selectProvider } from "./providers/index.js";
|
|
10
|
+
import { SessionPersistence } from "./sessions/runtime.js";
|
|
11
|
+
import { DurableSessionStore } from "./sessions/store.js";
|
|
8
12
|
import { builtinTools } from "./tools/index.js";
|
|
9
13
|
import { configureColor } from "./ui/render.js";
|
|
10
14
|
import { STEEL } from "./ui/theme.js";
|
|
11
|
-
import { emptyUsage } from "./usage.js";
|
|
15
|
+
import { emptyUsage, usageFromHistory } from "./usage.js";
|
|
12
16
|
import { runApp } from "./tui/app.js";
|
|
13
17
|
import { interactive } from "./tui/screen.js";
|
|
14
18
|
export async function start(args = process.argv.slice(2), environment = {}) {
|
|
@@ -17,10 +21,17 @@ export async function start(args = process.argv.slice(2), environment = {}) {
|
|
|
17
21
|
const write = environment.write ?? ((text) => process.stdout.write(text));
|
|
18
22
|
if (await showCliInfo(args, applicationRoot, write))
|
|
19
23
|
return;
|
|
20
|
-
const
|
|
24
|
+
const launch = parseLaunch(args);
|
|
25
|
+
const config = loadConfig(launch.configArgs);
|
|
21
26
|
configureProviders(config);
|
|
22
27
|
const provider = selectProvider(config.providerId);
|
|
23
28
|
const hasScreen = environment.interactive?.() ?? interactive();
|
|
29
|
+
if (launch.kind === "resume" && !hasScreen) {
|
|
30
|
+
throw new Error("resume needs an interactive terminal");
|
|
31
|
+
}
|
|
32
|
+
if (launch.kind === "resume" && config.ephemeral) {
|
|
33
|
+
throw new Error("--ephemeral cannot be combined with resume");
|
|
34
|
+
}
|
|
24
35
|
// A provider whose catalogue is not fixed has no sensible default model.
|
|
25
36
|
// The TUI can ask; a pipe cannot, so batch mode still requires one up front.
|
|
26
37
|
const model = config.model === "" ? provider.defaultModel : config.model;
|
|
@@ -35,13 +46,65 @@ export async function start(args = process.argv.slice(2), environment = {}) {
|
|
|
35
46
|
palette: STEEL,
|
|
36
47
|
tools: builtinTools(),
|
|
37
48
|
system: systemPrompt(config),
|
|
38
|
-
|
|
49
|
+
conversation: ConversationTree.empty(),
|
|
39
50
|
usage: emptyUsage(),
|
|
40
51
|
};
|
|
41
52
|
if (hasScreen) {
|
|
42
|
-
|
|
53
|
+
if (!config.ephemeral) {
|
|
54
|
+
const store = await DurableSessionStore.open(config.root, environment.sessionsRoot);
|
|
55
|
+
if (launch.kind === "resume") {
|
|
56
|
+
const candidates = await SessionPersistence.candidates(store);
|
|
57
|
+
if (candidates.length === 0)
|
|
58
|
+
throw new Error("no resumable sessions found for this workspace");
|
|
59
|
+
const open = async (id) => {
|
|
60
|
+
const resumed = await SessionPersistence.resume(store, id);
|
|
61
|
+
try {
|
|
62
|
+
applyResumedSession(session, resumed.conversation, resumed.persistence);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
await resumed.persistence.close();
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
if (launch.latest)
|
|
70
|
+
await open(candidates[0].id);
|
|
71
|
+
else
|
|
72
|
+
session.resume = { candidates, open };
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
session.persistence = SessionPersistence.fresh(store);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await (environment.runInteractive ?? runApp)(session, transcriptRoot);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await session.persistence?.close();
|
|
83
|
+
}
|
|
43
84
|
}
|
|
44
85
|
else {
|
|
45
86
|
await (environment.runNonInteractive ?? runBatch)(session);
|
|
46
87
|
}
|
|
47
88
|
}
|
|
89
|
+
function applyResumedSession(session, conversation, persistence) {
|
|
90
|
+
const identity = conversation.activeNode?.identity;
|
|
91
|
+
if (identity === undefined)
|
|
92
|
+
throw new Error("resumed session has no active turn");
|
|
93
|
+
const provider = selectProvider(identity.providerId);
|
|
94
|
+
const config = {
|
|
95
|
+
...session.config,
|
|
96
|
+
providerId: identity.providerId,
|
|
97
|
+
model: identity.model,
|
|
98
|
+
effort: identity.effort,
|
|
99
|
+
};
|
|
100
|
+
const system = systemPrompt(config);
|
|
101
|
+
const usage = usageFromHistory(conversation.history);
|
|
102
|
+
session.config = config;
|
|
103
|
+
session.provider = provider;
|
|
104
|
+
session.model = identity.model;
|
|
105
|
+
session.system = system;
|
|
106
|
+
session.conversation = conversation;
|
|
107
|
+
session.usage = usage;
|
|
108
|
+
session.persistence = persistence;
|
|
109
|
+
session.resume = undefined;
|
|
110
|
+
}
|
package/dist/tui/activity.js
CHANGED
|
@@ -9,3 +9,6 @@ export function elapsed(activity, now = Date.now()) {
|
|
|
9
9
|
const minutes = Math.floor(seconds / 60);
|
|
10
10
|
return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
11
11
|
}
|
|
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
|
@@ -21,6 +21,15 @@ export function appInput(options) {
|
|
|
21
21
|
}
|
|
22
22
|
if (state.feedback !== undefined)
|
|
23
23
|
feedback.dismiss();
|
|
24
|
+
// Detail expansion remains available while an approval is open. A large
|
|
25
|
+
// diff may be compacted, but the user must be able to inspect it before
|
|
26
|
+
// answering the permission prompt.
|
|
27
|
+
if (key.ctrl && key.name === "o") {
|
|
28
|
+
const changed = toggleDetails(state.blocks);
|
|
29
|
+
if (changed !== undefined)
|
|
30
|
+
options.transcriptChanged(changed);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
24
33
|
if (state.open !== undefined) {
|
|
25
34
|
const outcome = overlay.handle(state.open, key);
|
|
26
35
|
state.open = outcome.open;
|
|
@@ -95,12 +104,6 @@ export function appInput(options) {
|
|
|
95
104
|
options.invalidate();
|
|
96
105
|
return;
|
|
97
106
|
}
|
|
98
|
-
if (key.ctrl && key.name === "o") {
|
|
99
|
-
const changed = toggleDetails(state.blocks);
|
|
100
|
-
if (changed !== undefined)
|
|
101
|
-
options.transcriptChanged(changed);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
107
|
const edited = applyKey(state.editor, key);
|
|
105
108
|
if (edited !== undefined) {
|
|
106
109
|
state.editor = edited;
|
|
@@ -39,7 +39,8 @@ export function appWorkflows(options) {
|
|
|
39
39
|
state.status = said ?? activity.label;
|
|
40
40
|
options.render();
|
|
41
41
|
},
|
|
42
|
-
reset: () => {
|
|
42
|
+
reset: async () => {
|
|
43
|
+
await session.persistence?.reset();
|
|
43
44
|
state.blocks.splice(0);
|
|
44
45
|
state.past.length = 0;
|
|
45
46
|
permissions.reset();
|
|
@@ -59,11 +60,16 @@ export function appWorkflows(options) {
|
|
|
59
60
|
state.closeWhenIdle = true;
|
|
60
61
|
}
|
|
61
62
|
catch (error) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
// Command cancellation is already visible through the dock closing and
|
|
64
|
+
// the activity ending. Keep it silent instead of replacing the footer
|
|
65
|
+
// with a redundant warning.
|
|
66
|
+
if (!activity.control.signal.aborted) {
|
|
67
|
+
feedback.show({
|
|
68
|
+
text: error.message,
|
|
69
|
+
tone: "error",
|
|
70
|
+
timeoutMs: 6_000,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
67
73
|
}
|
|
68
74
|
finally {
|
|
69
75
|
options.finishActivity(activity);
|
|
@@ -73,8 +79,14 @@ export function appWorkflows(options) {
|
|
|
73
79
|
const activity = options.startActivity("turn", WAITING);
|
|
74
80
|
if (activity === undefined)
|
|
75
81
|
return;
|
|
82
|
+
const parentId = session.conversation.activeNodeId;
|
|
83
|
+
const history = session.conversation.history;
|
|
84
|
+
const historyStart = history.length;
|
|
85
|
+
const blockStart = state.blocks.length;
|
|
86
|
+
const createdAt = new Date().toISOString();
|
|
87
|
+
let nodeId;
|
|
76
88
|
options.emit({ kind: "user", text });
|
|
77
|
-
|
|
89
|
+
history.push({ role: "user", content: [{ kind: "text", text }] });
|
|
78
90
|
const events = transcribe({
|
|
79
91
|
emit: options.emit,
|
|
80
92
|
render: options.render,
|
|
@@ -90,9 +102,26 @@ export function appWorkflows(options) {
|
|
|
90
102
|
},
|
|
91
103
|
usage: (usage) => recordUsage(session.usage, usage),
|
|
92
104
|
});
|
|
105
|
+
events.onCheckpoint = async (checkpoint, settlement) => {
|
|
106
|
+
const next = session.conversation.commit({
|
|
107
|
+
...(nodeId === undefined ? {} : { nodeId }),
|
|
108
|
+
parentId,
|
|
109
|
+
createdAt,
|
|
110
|
+
identity: {
|
|
111
|
+
providerId: session.provider.id,
|
|
112
|
+
model: session.model,
|
|
113
|
+
effort: session.config.effort,
|
|
114
|
+
},
|
|
115
|
+
messages: checkpoint.slice(historyStart),
|
|
116
|
+
blocks: state.blocks.slice(blockStart),
|
|
117
|
+
}, settlement);
|
|
118
|
+
await session.persistence?.checkpoint(next);
|
|
119
|
+
session.conversation = next;
|
|
120
|
+
nodeId = next.activeNodeId;
|
|
121
|
+
};
|
|
93
122
|
let finishReason;
|
|
94
123
|
try {
|
|
95
|
-
await runTurn(
|
|
124
|
+
await runTurn(history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal);
|
|
96
125
|
}
|
|
97
126
|
catch (error) {
|
|
98
127
|
const interrupted = activity.control.signal.aborted;
|
package/dist/tui/app.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Everything mutable about a session lives in `state` here. The view is a pure
|
|
4
4
|
// function of it, so the only job of a key handler is to change state and ask
|
|
5
5
|
// for a repaint — never to draw.
|
|
6
|
-
import { begin } from "./activity.js";
|
|
6
|
+
import { activityStatus, begin } from "./activity.js";
|
|
7
7
|
import * as overlay from "./overlay.js";
|
|
8
8
|
import { options as completionOptions } from "./complete.js";
|
|
9
9
|
import { decoder } from "./keys.js";
|
|
@@ -19,6 +19,7 @@ import { appState } from "./app-state.js";
|
|
|
19
19
|
import { appInput } from "./app-input.js";
|
|
20
20
|
import { appWorkflows } from "./app-workflows.js";
|
|
21
21
|
import { sessionPermissions } from "../permissions.js";
|
|
22
|
+
import { resumePicker } from "./resume.js";
|
|
22
23
|
const FRAME_MS = 16;
|
|
23
24
|
const SPIN_MS = 80;
|
|
24
25
|
/** How long a lone escape waits to prove it is not the start of a sequence. */
|
|
@@ -26,10 +27,11 @@ const ESCAPE_MS = 25;
|
|
|
26
27
|
export async function runApp(session, transcriptRoot, environment = {}) {
|
|
27
28
|
const terminal = environment.screen ?? realScreen;
|
|
28
29
|
const paint = environment.paint ?? painter();
|
|
29
|
-
const keys = decoder();
|
|
30
|
+
const keys = decoder({ ctrlBackspaceIsBs: process.env["WT_SESSION"] !== undefined });
|
|
30
31
|
const transcript = transcriptRenderer();
|
|
31
32
|
const workspace = await workspaceLabel(session.config.root);
|
|
32
33
|
const state = appState();
|
|
34
|
+
state.blocks.push(...session.conversation.transcript);
|
|
33
35
|
const permissions = sessionPermissions(session.tools, session.config.autoApprove);
|
|
34
36
|
let closed;
|
|
35
37
|
let frameTimer;
|
|
@@ -43,23 +45,28 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
43
45
|
const done = new Promise((resolve) => {
|
|
44
46
|
closed = resolve;
|
|
45
47
|
});
|
|
46
|
-
const view = () =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
48
|
+
const view = () => {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
return {
|
|
51
|
+
blocks: state.blocks,
|
|
52
|
+
editor: state.editor,
|
|
53
|
+
scroll: state.scroll,
|
|
54
|
+
unseen: state.unseen,
|
|
55
|
+
pal: session.palette,
|
|
56
|
+
footer: footerInfo(session, workspace),
|
|
57
|
+
status: state.activity === undefined
|
|
58
|
+
? undefined
|
|
59
|
+
: activityStatus(state.activity, state.status ?? state.activity.label, now),
|
|
60
|
+
feedback: state.feedback,
|
|
61
|
+
readiness: turnBlocker(session),
|
|
62
|
+
spin: state.spin,
|
|
63
|
+
reducedMotion: session.config.reducedMotion,
|
|
64
|
+
now,
|
|
65
|
+
modal: overlay.shown(state.open),
|
|
66
|
+
menu: completionOptions(state.completing),
|
|
67
|
+
menuIndex: state.completing?.index,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
63
70
|
const draw = () => {
|
|
64
71
|
frameTimer = undefined;
|
|
65
72
|
if (!live)
|
|
@@ -201,6 +208,44 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
201
208
|
invalidate: () => paint.invalidate(),
|
|
202
209
|
transcriptChanged: (block) => transcript.invalidate(block),
|
|
203
210
|
});
|
|
211
|
+
const resumeAtLaunch = session.resume === undefined
|
|
212
|
+
? undefined
|
|
213
|
+
: openResumedSession(session.resume);
|
|
214
|
+
async function openResumedSession(launch) {
|
|
215
|
+
while (live) {
|
|
216
|
+
const index = await new Promise((resolve) => {
|
|
217
|
+
state.open = { picker: resumePicker(launch.candidates, session.palette), settle: resolve };
|
|
218
|
+
render();
|
|
219
|
+
});
|
|
220
|
+
if (index === undefined) {
|
|
221
|
+
quit();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const candidate = launch.candidates[index];
|
|
225
|
+
if (candidate === undefined)
|
|
226
|
+
continue;
|
|
227
|
+
const activity = startActivity("command", "Opening session");
|
|
228
|
+
if (activity === undefined)
|
|
229
|
+
return;
|
|
230
|
+
try {
|
|
231
|
+
await launch.open(candidate.id);
|
|
232
|
+
state.blocks.splice(0, state.blocks.length, ...session.conversation.transcript);
|
|
233
|
+
state.past.length = 0;
|
|
234
|
+
state.scroll = 0;
|
|
235
|
+
state.follow = true;
|
|
236
|
+
state.unseen = 0;
|
|
237
|
+
state.lastMaxScroll = 0;
|
|
238
|
+
transcript.invalidate();
|
|
239
|
+
paint.invalidate();
|
|
240
|
+
finishActivity(activity);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
feedback.show({ text: error.message, tone: "error", timeoutMs: 6_000 });
|
|
245
|
+
finishActivity(activity);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
204
249
|
terminal.enter(session.config.reducedMotion);
|
|
205
250
|
stopResize = terminal.onResize(() => {
|
|
206
251
|
paint.invalidate();
|
|
@@ -228,5 +273,12 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
228
273
|
render();
|
|
229
274
|
});
|
|
230
275
|
draw();
|
|
231
|
-
|
|
276
|
+
try {
|
|
277
|
+
await resumeAtLaunch;
|
|
278
|
+
if (live)
|
|
279
|
+
await done;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
await session.persistence?.close();
|
|
283
|
+
}
|
|
232
284
|
}
|
|
@@ -7,7 +7,7 @@ export function renderComposer(editor, width, maxInputRows, pal, right = "") {
|
|
|
7
7
|
const first = Math.max(0, Math.min(laid.lines.length - room, laid.cursor.row));
|
|
8
8
|
const shown = laid.lines.slice(first, first + room);
|
|
9
9
|
return {
|
|
10
|
-
rows: shown.map((line, index) => row(width, [{ text: line, fg: pal.ink.bright }], index === 0 && right !== "" ? [{ text: right, fg: pal.ink.
|
|
10
|
+
rows: shown.map((line, index) => row(width, [{ text: line, fg: pal.ink.bright }], index === 0 && right !== "" ? [{ text: right, fg: pal.ink.dim }] : [])),
|
|
11
11
|
cursor: { row: laid.cursor.row - first, col: laid.cursor.col },
|
|
12
12
|
};
|
|
13
13
|
}
|
|
@@ -5,7 +5,7 @@ export function renderFooter(info, status, width, pal) {
|
|
|
5
5
|
const right = fitSegs(status, rightLimit);
|
|
6
6
|
const leftRoom = Math.max(0, width - plainLen(right) - (right.length === 0 ? 0 : 1));
|
|
7
7
|
const left = identity(info, leftRoom);
|
|
8
|
-
return [row(width, left === "" ? [] : [{ text: left, fg: pal.ink.
|
|
8
|
+
return [row(width, left === "" ? [] : [{ text: left, fg: pal.ink.dim }], right)];
|
|
9
9
|
}
|
|
10
10
|
function identity(info, cols) {
|
|
11
11
|
if (cols <= 0)
|
|
@@ -16,28 +16,40 @@ export function menuWindow(length, selected, visible) {
|
|
|
16
16
|
return { first, last: Math.min(count, first + room) };
|
|
17
17
|
}
|
|
18
18
|
function renderEntry(entry, labelWidth, width, pal) {
|
|
19
|
-
// Colour terminals
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// Colour terminals spend focus on the active label instead of painting a
|
|
20
|
+
// full-width band. Monochrome has no colour, so it alone reserves a fixed
|
|
21
|
+
// arrow column to keep selection visible without shifting peer rows.
|
|
22
22
|
const monochrome = !hasColor();
|
|
23
23
|
const selectedMark = monochrome ? (entry.selected ? "→ " : " ") : "";
|
|
24
|
-
const fg = entry.selected ? pal.
|
|
24
|
+
const fg = entry.selected ? pal.focus : pal.ink.fg;
|
|
25
25
|
const primary = [
|
|
26
|
-
{ text: selectedMark, fg
|
|
27
|
-
{ text: entry.label, fg },
|
|
26
|
+
{ text: selectedMark, fg },
|
|
27
|
+
{ text: entry.label, fg, bold: entry.selected || undefined },
|
|
28
28
|
];
|
|
29
29
|
if (width > 40 && entry.description !== undefined) {
|
|
30
30
|
const gap = Math.max(2, labelWidth - primaryWidth(entry));
|
|
31
|
-
primary.push({
|
|
31
|
+
primary.push({
|
|
32
|
+
text: `${" ".repeat(gap)}${entry.description}`,
|
|
33
|
+
fg: entry.selected ? pal.ink.bright : pal.ink.muted,
|
|
34
|
+
});
|
|
32
35
|
}
|
|
33
|
-
const
|
|
34
|
-
?
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
const value = entry.value === undefined
|
|
37
|
+
? undefined
|
|
38
|
+
: entry.selected && entry.adjustable === true
|
|
39
|
+
? `‹ ${entry.value} ›`
|
|
40
|
+
: entry.value;
|
|
41
|
+
const summary = [entry.hint, value]
|
|
42
|
+
.filter((part) => part !== undefined)
|
|
43
|
+
.join(" · ");
|
|
44
|
+
const rightColor = entry.selected ? pal.focus : pal.ink.muted;
|
|
45
|
+
const right = summary === "" || (width <= 40 && entry.value === undefined)
|
|
46
|
+
? []
|
|
47
|
+
: [{
|
|
48
|
+
text: elide(summary, Math.max(1, Math.floor(entry.value === undefined ? width / 4 : width * 0.45))),
|
|
49
|
+
fg: rightColor,
|
|
50
|
+
bold: entry.selected || undefined,
|
|
51
|
+
}];
|
|
52
|
+
return row(width, primary, right);
|
|
41
53
|
}
|
|
42
54
|
function primaryWidth(entry) {
|
|
43
55
|
return plainLen([{ text: entry.label }]);
|
|
@@ -31,23 +31,9 @@ export function renderReasoning(block, width, pal) {
|
|
|
31
31
|
: { text: block.text, truncated: false };
|
|
32
32
|
const content = markdown(source.text, inner, pal, inner);
|
|
33
33
|
const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
|
|
34
|
-
const action = expanded
|
|
35
|
-
? "ctrl+o compact"
|
|
36
|
-
: block.live === true && block.expanded === true
|
|
37
|
-
? "full when done"
|
|
38
|
-
: source.truncated || content.length > REASONING_PREVIEW_ROWS
|
|
39
|
-
? "ctrl+o full"
|
|
40
|
-
: undefined;
|
|
41
34
|
return [
|
|
42
35
|
"",
|
|
43
|
-
row(width, [
|
|
44
|
-
{
|
|
45
|
-
text: block.live === true ? "thinking" : "thought",
|
|
46
|
-
fg: pal.ink.bright,
|
|
47
|
-
bold: true,
|
|
48
|
-
},
|
|
49
|
-
], action === undefined ? [] : [{ text: action, fg: pal.ink.muted }], undefined, PAD),
|
|
50
|
-
...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.muted, italic: true })), [], undefined, PAD)),
|
|
36
|
+
...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })), [], undefined, PAD)),
|
|
51
37
|
];
|
|
52
38
|
}
|
|
53
39
|
export function reasoningPreviewSource(text, width) {
|
|
@@ -7,10 +7,10 @@ export const PROMPT_WIDTH = textWidth(MARK);
|
|
|
7
7
|
export function promptLine(text, cursor, width, pal, options = {}) {
|
|
8
8
|
const right = options.right === undefined || options.right === ""
|
|
9
9
|
? []
|
|
10
|
-
: [{ text: options.right, fg: pal.ink.
|
|
10
|
+
: [{ text: options.right, fg: pal.ink.dim }];
|
|
11
11
|
const laid = layout(text, cursor, width, options);
|
|
12
12
|
const content = laid.visible === "" && options.placeholder !== undefined
|
|
13
|
-
? { text: options.placeholder, fg: pal.ink.
|
|
13
|
+
? { text: options.placeholder, fg: pal.ink.dim }
|
|
14
14
|
: { text: laid.visible, fg: pal.ink.bright };
|
|
15
15
|
return {
|
|
16
16
|
row: row(width, [{ text: MARK, fg: pal.accent }, content], right),
|
|
@@ -6,7 +6,10 @@ export function renderStatus(info, pal) {
|
|
|
6
6
|
if (urgent !== undefined)
|
|
7
7
|
return withUnseen(feedbackSegments(urgent, pal), info.unseen, pal);
|
|
8
8
|
if (info.status !== undefined) {
|
|
9
|
-
return withUnseen([
|
|
9
|
+
return withUnseen([
|
|
10
|
+
{ text: info.status, fg: pal.ink.muted },
|
|
11
|
+
{ text: " · esc to interrupt", fg: pal.ink.dim, optional: true },
|
|
12
|
+
], info.unseen, pal);
|
|
10
13
|
}
|
|
11
14
|
if (info.feedback !== undefined) {
|
|
12
15
|
return withUnseen(feedbackSegments(info.feedback, pal), info.unseen, pal);
|
|
@@ -30,7 +33,7 @@ function feedbackSegments(feedback, pal) {
|
|
|
30
33
|
function withUnseen(status, unseen, pal) {
|
|
31
34
|
return unseen === 0
|
|
32
35
|
? status
|
|
33
|
-
: [...status, { text: " · ", fg: pal.ink.
|
|
36
|
+
: [...status, { text: " · ", fg: pal.ink.dim }, ...unseenSegments(unseen, pal)];
|
|
34
37
|
}
|
|
35
38
|
function unseenSegments(unseen, pal) {
|
|
36
39
|
return [{ text: `${unseen} new ↓`, fg: pal.accent, bold: true }];
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { hasColor, row } from "../../ui/render.js";
|
|
3
3
|
const OUTPUT_ROWS = 8;
|
|
4
4
|
const LIVE_OUTPUT_ROWS = 6;
|
|
5
|
+
const DIFF_ROWS = 15;
|
|
5
6
|
export function renderTool(block, width, pal, context = {}) {
|
|
6
7
|
const shown = visibleDetails(block);
|
|
7
8
|
const right = liveLabel(block, context);
|
|
@@ -11,7 +12,7 @@ export function renderTool(block, width, pal, context = {}) {
|
|
|
11
12
|
{ text: " " },
|
|
12
13
|
{ text: `${stateGlyph(block, context)} `, fg: statusInk(block.tone, pal), bold: true },
|
|
13
14
|
{ text: block.name, fg: pal.ink.bright, bold: true },
|
|
14
|
-
...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.
|
|
15
|
+
...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.technical }]),
|
|
15
16
|
], right === "" ? [] : [{ text: right, fg: statusInk(block.tone, pal) }]),
|
|
16
17
|
...shown.map((detail) => renderDetail(detail, block.tone, width, pal)),
|
|
17
18
|
];
|
|
@@ -31,8 +32,24 @@ function visibleDetails(block) {
|
|
|
31
32
|
return [{ kind: "gap", text: note }, ...all.slice(-limit)];
|
|
32
33
|
}
|
|
33
34
|
// The compact transcript is an audit of what changed, not a code excerpt.
|
|
34
|
-
//
|
|
35
|
-
|
|
35
|
+
// One shared budget applies to writes and edits. Keep both ends so a large
|
|
36
|
+
// replacement cannot show only deletions while hiding all new content.
|
|
37
|
+
// Context, omitted changes, and gap rows remain in semantic state for the
|
|
38
|
+
// explicit full view.
|
|
39
|
+
const changed = all.filter((detail) => detail.kind === "add" || detail.kind === "del");
|
|
40
|
+
if (changed.length <= DIFF_ROWS)
|
|
41
|
+
return changed;
|
|
42
|
+
const leading = Math.ceil(DIFF_ROWS / 2);
|
|
43
|
+
const trailing = DIFF_ROWS - leading;
|
|
44
|
+
const hidden = changed.length - DIFF_ROWS;
|
|
45
|
+
return [
|
|
46
|
+
...changed.slice(0, leading),
|
|
47
|
+
{
|
|
48
|
+
kind: "gap",
|
|
49
|
+
text: `… ${hidden} more changed ${hidden === 1 ? "line" : "lines"} · ctrl+o expand`,
|
|
50
|
+
},
|
|
51
|
+
...changed.slice(-trailing),
|
|
52
|
+
];
|
|
36
53
|
}
|
|
37
54
|
function renderDetail(detail, tone, width, pal) {
|
|
38
55
|
const lead = { text: " " };
|
|
@@ -42,7 +59,7 @@ function renderDetail(detail, tone, width, pal) {
|
|
|
42
59
|
return row(width, [lead, rail, { text: detail.text === "" ? " " : detail.text, fg }]);
|
|
43
60
|
}
|
|
44
61
|
if (detail.kind === "gap") {
|
|
45
|
-
return row(width, [lead, rail, { text: detail.text, fg: pal.ink.
|
|
62
|
+
return row(width, [lead, rail, { text: detail.text, fg: pal.ink.dim, italic: true }]);
|
|
46
63
|
}
|
|
47
64
|
const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
|
|
48
65
|
const prefix = `${detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " "}${String(number ?? "").padStart(3)} `;
|
|
@@ -50,7 +67,7 @@ function renderDetail(detail, tone, width, pal) {
|
|
|
50
67
|
? pal.ink.added
|
|
51
68
|
: detail.kind === "del"
|
|
52
69
|
? pal.ink.removed
|
|
53
|
-
: pal.ink.
|
|
70
|
+
: pal.ink.dim;
|
|
54
71
|
return row(width, [lead, rail, { text: prefix, fg }, ...emphasized(detail.text, detail.emphasis, fg)]);
|
|
55
72
|
}
|
|
56
73
|
function emphasized(text, emphasis, fg) {
|
package/dist/tui/editor.js
CHANGED
|
@@ -43,15 +43,25 @@ export function wordLeft(state) {
|
|
|
43
43
|
return { ...state, cursor: startOfWordBefore(state.text, state.cursor) };
|
|
44
44
|
}
|
|
45
45
|
export function wordRight(state) {
|
|
46
|
-
return { ...state, cursor:
|
|
46
|
+
return { ...state, cursor: startOfWordAfter(state.text, state.cursor) };
|
|
47
47
|
}
|
|
48
48
|
/** ctrl+w — delete the word behind the cursor. */
|
|
49
49
|
export function killWord(state) {
|
|
50
|
-
const from =
|
|
50
|
+
const from = startOfDeletionBefore(state.text, state.cursor);
|
|
51
51
|
if (from === state.cursor)
|
|
52
52
|
return state;
|
|
53
53
|
return { text: state.text.slice(0, from) + state.text.slice(state.cursor), cursor: from };
|
|
54
54
|
}
|
|
55
|
+
/** ctrl+delete — delete the word ahead of the cursor. */
|
|
56
|
+
export function killNextWord(state) {
|
|
57
|
+
const to = endOfDeletionAfter(state.text, state.cursor);
|
|
58
|
+
if (to === state.cursor)
|
|
59
|
+
return state;
|
|
60
|
+
return {
|
|
61
|
+
text: state.text.slice(0, state.cursor) + state.text.slice(to),
|
|
62
|
+
cursor: state.cursor,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
55
65
|
/** ctrl+u — delete everything behind the cursor. */
|
|
56
66
|
export function killToStart(state) {
|
|
57
67
|
return { text: state.text.slice(state.cursor), cursor: 0 };
|
|
@@ -89,17 +99,54 @@ function after(text, cursor) {
|
|
|
89
99
|
}
|
|
90
100
|
function startOfWordBefore(text, cursor) {
|
|
91
101
|
let i = cursor;
|
|
92
|
-
while (i > 0 && text[i - 1]
|
|
102
|
+
while (i > 0 && whitespace(text[i - 1]))
|
|
93
103
|
i--;
|
|
94
|
-
while (i > 0 && text[i - 1]
|
|
104
|
+
while (i > 0 && !whitespace(text[i - 1]))
|
|
95
105
|
i--;
|
|
96
106
|
return i;
|
|
97
107
|
}
|
|
98
|
-
function
|
|
108
|
+
function startOfWordAfter(text, cursor) {
|
|
99
109
|
let i = cursor;
|
|
100
|
-
while (i < text.length && text[i]
|
|
110
|
+
while (i < text.length && !whitespace(text[i]))
|
|
101
111
|
i++;
|
|
102
|
-
while (i < text.length && text[i]
|
|
112
|
+
while (i < text.length && whitespace(text[i]))
|
|
103
113
|
i++;
|
|
104
114
|
return i;
|
|
105
115
|
}
|
|
116
|
+
function startOfDeletionBefore(text, cursor) {
|
|
117
|
+
if (text[cursor - 1] === "\n")
|
|
118
|
+
return cursor - 1;
|
|
119
|
+
let i = cursor;
|
|
120
|
+
while (i > 0 && horizontalWhitespace(text[i - 1]))
|
|
121
|
+
i--;
|
|
122
|
+
if (text[i - 1] === "\n")
|
|
123
|
+
return i;
|
|
124
|
+
while (i > 0 && !whitespace(text[i - 1]))
|
|
125
|
+
i--;
|
|
126
|
+
return i;
|
|
127
|
+
}
|
|
128
|
+
function endOfDeletionAfter(text, cursor) {
|
|
129
|
+
if (text[cursor] === "\n")
|
|
130
|
+
return cursor + 1;
|
|
131
|
+
let i = cursor;
|
|
132
|
+
if (horizontalWhitespace(text[i])) {
|
|
133
|
+
while (i < text.length && horizontalWhitespace(text[i]))
|
|
134
|
+
i++;
|
|
135
|
+
if (text[i] === "\n")
|
|
136
|
+
return i;
|
|
137
|
+
while (i < text.length && !whitespace(text[i]))
|
|
138
|
+
i++;
|
|
139
|
+
return i;
|
|
140
|
+
}
|
|
141
|
+
while (i < text.length && !whitespace(text[i]))
|
|
142
|
+
i++;
|
|
143
|
+
while (i < text.length && horizontalWhitespace(text[i]))
|
|
144
|
+
i++;
|
|
145
|
+
return i;
|
|
146
|
+
}
|
|
147
|
+
function horizontalWhitespace(value) {
|
|
148
|
+
return value !== undefined && value !== "\n" && /\s/u.test(value);
|
|
149
|
+
}
|
|
150
|
+
function whitespace(value) {
|
|
151
|
+
return value !== undefined && /\s/u.test(value);
|
|
152
|
+
}
|