@giovannijecha/jecode 0.1.5-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/bin/jecode.js +11 -0
- package/dist/atomic.js +78 -0
- package/dist/batch-view.js +17 -0
- package/dist/batch.js +100 -0
- package/dist/cli-info.js +40 -0
- package/dist/commands.js +171 -0
- package/dist/config.js +97 -0
- package/dist/controller.js +90 -0
- package/dist/credential-commands.js +134 -0
- package/dist/credential-safety.js +86 -0
- package/dist/credentials.js +124 -0
- package/dist/main.js +7 -0
- package/dist/ollama-settings-command.js +75 -0
- package/dist/prompt.js +29 -0
- package/dist/provider-commands.js +236 -0
- package/dist/provider-errors.js +10 -0
- package/dist/providers/anthropic-stream.js +111 -0
- package/dist/providers/anthropic-wire.js +79 -0
- package/dist/providers/anthropic.js +77 -0
- package/dist/providers/catalog.js +37 -0
- package/dist/providers/http.js +219 -0
- package/dist/providers/index.js +18 -0
- package/dist/providers/ollama-endpoint.js +40 -0
- package/dist/providers/ollama-stream.js +60 -0
- package/dist/providers/ollama-wire.js +95 -0
- package/dist/providers/ollama.js +87 -0
- package/dist/providers/openai-stream.js +48 -0
- package/dist/providers/openai-wire.js +112 -0
- package/dist/providers/openai.js +70 -0
- package/dist/providers/sse.js +81 -0
- package/dist/providers/stream-limits.js +11 -0
- package/dist/session.js +3 -0
- package/dist/settings-command.js +202 -0
- package/dist/settings.js +98 -0
- package/dist/start.js +47 -0
- package/dist/tools/args.js +38 -0
- package/dist/tools/fs.js +277 -0
- package/dist/tools/index.js +39 -0
- package/dist/tools/paths.js +122 -0
- package/dist/tools/search.js +213 -0
- package/dist/tools/shell.js +139 -0
- package/dist/tools/text-boundary.js +102 -0
- package/dist/tools/types.js +1 -0
- package/dist/transcript-export.js +11 -0
- package/dist/transcript.js +49 -0
- package/dist/tui/activity.js +11 -0
- package/dist/tui/app-input.js +155 -0
- package/dist/tui/app-state.js +17 -0
- package/dist/tui/app-workflows.js +105 -0
- package/dist/tui/app.js +215 -0
- package/dist/tui/approve.js +67 -0
- package/dist/tui/blocks.js +23 -0
- package/dist/tui/complete.js +54 -0
- package/dist/tui/components/command-menu.js +17 -0
- package/dist/tui/components/composer.js +47 -0
- package/dist/tui/components/dock.js +10 -0
- package/dist/tui/components/footer.js +21 -0
- package/dist/tui/components/menu.js +38 -0
- package/dist/tui/components/messages.js +43 -0
- package/dist/tui/components/misc.js +22 -0
- package/dist/tui/components/status.js +45 -0
- package/dist/tui/components/tool.js +85 -0
- package/dist/tui/components/types.js +1 -0
- package/dist/tui/editor.js +105 -0
- package/dist/tui/feedback.js +74 -0
- package/dist/tui/field.js +60 -0
- package/dist/tui/frame.js +33 -0
- package/dist/tui/input.js +52 -0
- package/dist/tui/keys.js +177 -0
- package/dist/tui/modal.js +24 -0
- package/dist/tui/overlay.js +93 -0
- package/dist/tui/picker.js +99 -0
- package/dist/tui/screen.js +94 -0
- package/dist/tui/scroll.js +7 -0
- package/dist/tui/session-view.js +49 -0
- package/dist/tui/transcript-view.js +134 -0
- package/dist/tui/turn.js +255 -0
- package/dist/tui/view.js +88 -0
- package/dist/tui/workspace.js +59 -0
- package/dist/types.js +9 -0
- package/dist/ui/diff.js +109 -0
- package/dist/ui/highlight.js +158 -0
- package/dist/ui/inline.js +39 -0
- package/dist/ui/markdown.js +147 -0
- package/dist/ui/render.js +232 -0
- package/dist/ui/table.js +127 -0
- package/dist/ui/terminal-text.js +42 -0
- package/dist/ui/theme.js +25 -0
- package/dist/ui/width.js +196 -0
- package/dist/usage.js +30 -0
- package/dist/user-data.js +29 -0
- package/package.json +56 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The TUI shell: keys in, frames out, one turn at a time.
|
|
2
|
+
//
|
|
3
|
+
// Everything mutable about a session lives in `state` here. The view is a pure
|
|
4
|
+
// function of it, so the only job of a key handler is to change state and ask
|
|
5
|
+
// for a repaint — never to draw.
|
|
6
|
+
import { begin, elapsed } from "./activity.js";
|
|
7
|
+
import * as overlay from "./overlay.js";
|
|
8
|
+
import { options as completionOptions } from "./complete.js";
|
|
9
|
+
import { decoder } from "./keys.js";
|
|
10
|
+
import { painter } from "./frame.js";
|
|
11
|
+
import { commandFeedback, feedbackController, turnBlocker } from "./feedback.js";
|
|
12
|
+
import * as realScreen from "./screen.js";
|
|
13
|
+
import { compose } from "./view.js";
|
|
14
|
+
import { preserveOffset } from "./scroll.js";
|
|
15
|
+
import { footerInfo } from "./session-view.js";
|
|
16
|
+
import { workspaceLabel } from "./workspace.js";
|
|
17
|
+
import { transcriptRenderer } from "./transcript-view.js";
|
|
18
|
+
import { appState } from "./app-state.js";
|
|
19
|
+
import { appInput } from "./app-input.js";
|
|
20
|
+
import { appWorkflows } from "./app-workflows.js";
|
|
21
|
+
const FRAME_MS = 16;
|
|
22
|
+
const SPIN_MS = 80;
|
|
23
|
+
/** How long a lone escape waits to prove it is not the start of a sequence. */
|
|
24
|
+
const ESCAPE_MS = 25;
|
|
25
|
+
export async function runApp(session, transcriptRoot, environment = {}) {
|
|
26
|
+
const terminal = environment.screen ?? realScreen;
|
|
27
|
+
const paint = environment.paint ?? painter();
|
|
28
|
+
const keys = decoder();
|
|
29
|
+
const transcript = transcriptRenderer();
|
|
30
|
+
const workspace = await workspaceLabel(session.config.root);
|
|
31
|
+
const state = appState();
|
|
32
|
+
// Tools the user said "always" to. It lives for the window and dies with it:
|
|
33
|
+
// a permission granted once, in one conversation, is not a setting.
|
|
34
|
+
const allowed = new Map();
|
|
35
|
+
let closed;
|
|
36
|
+
let frameTimer;
|
|
37
|
+
let spinTimer;
|
|
38
|
+
let stopResize = () => { };
|
|
39
|
+
let stopInput = () => { };
|
|
40
|
+
// Timers outlive the teardown they were scheduled before. Painting after the
|
|
41
|
+
// terminal has been handed back would write escapes into the user's shell.
|
|
42
|
+
let live = true;
|
|
43
|
+
const done = new Promise((resolve) => {
|
|
44
|
+
closed = resolve;
|
|
45
|
+
});
|
|
46
|
+
const view = () => ({
|
|
47
|
+
blocks: state.blocks,
|
|
48
|
+
editor: state.editor,
|
|
49
|
+
scroll: state.scroll,
|
|
50
|
+
unseen: state.unseen,
|
|
51
|
+
pal: session.palette,
|
|
52
|
+
footer: footerInfo(session, workspace),
|
|
53
|
+
status: state.status === undefined || state.activity === undefined
|
|
54
|
+
? state.status
|
|
55
|
+
: `${state.status} · ${elapsed(state.activity)}`,
|
|
56
|
+
feedback: state.feedback,
|
|
57
|
+
readiness: turnBlocker(session),
|
|
58
|
+
spin: state.spin,
|
|
59
|
+
reducedMotion: session.config.reducedMotion,
|
|
60
|
+
modal: overlay.shown(state.open),
|
|
61
|
+
menu: completionOptions(state.completing),
|
|
62
|
+
menuIndex: state.completing?.index,
|
|
63
|
+
});
|
|
64
|
+
const draw = () => {
|
|
65
|
+
frameTimer = undefined;
|
|
66
|
+
if (!live)
|
|
67
|
+
return;
|
|
68
|
+
let frame = compose(view(), terminal.size(), transcript);
|
|
69
|
+
// Preserve the exact rows being read while streaming grows the transcript.
|
|
70
|
+
// `scroll` is measured from the bottom, so new rows increase that offset.
|
|
71
|
+
if (!state.follow && frame.maxScroll > state.lastMaxScroll) {
|
|
72
|
+
state.scroll = preserveOffset(state.scroll, state.follow, state.lastMaxScroll, frame.maxScroll);
|
|
73
|
+
frame = compose(view(), terminal.size(), transcript);
|
|
74
|
+
}
|
|
75
|
+
// Composing is what discovers how far there is to scroll, so the clamp
|
|
76
|
+
// lands here rather than in every place that moves the viewport.
|
|
77
|
+
state.scroll = Math.min(state.scroll, frame.maxScroll);
|
|
78
|
+
if (state.scroll === 0) {
|
|
79
|
+
state.follow = true;
|
|
80
|
+
state.unseen = 0;
|
|
81
|
+
}
|
|
82
|
+
state.lastMaxScroll = frame.maxScroll;
|
|
83
|
+
paint.paint(frame.rows, frame.cursor);
|
|
84
|
+
};
|
|
85
|
+
// Streaming produces a token at a time; painting at that rate is wasted
|
|
86
|
+
// work, so repaints coalesce onto one frame.
|
|
87
|
+
const render = (block) => {
|
|
88
|
+
if (block !== undefined)
|
|
89
|
+
transcript.invalidate(block);
|
|
90
|
+
if (live && frameTimer === undefined)
|
|
91
|
+
frameTimer = setTimeout(draw, FRAME_MS);
|
|
92
|
+
};
|
|
93
|
+
const feedback = feedbackController((next) => {
|
|
94
|
+
state.feedback = next;
|
|
95
|
+
render();
|
|
96
|
+
});
|
|
97
|
+
const emit = (block) => {
|
|
98
|
+
state.blocks.push(block);
|
|
99
|
+
if (state.follow)
|
|
100
|
+
state.scroll = 0;
|
|
101
|
+
else
|
|
102
|
+
state.unseen++;
|
|
103
|
+
};
|
|
104
|
+
const commandOutput = (block) => {
|
|
105
|
+
const next = commandFeedback(block);
|
|
106
|
+
if (next === undefined)
|
|
107
|
+
emit(block);
|
|
108
|
+
else
|
|
109
|
+
feedback.show(next);
|
|
110
|
+
};
|
|
111
|
+
const scrollBy = (amount) => {
|
|
112
|
+
state.scroll = Math.max(0, state.scroll + amount);
|
|
113
|
+
state.follow = state.scroll === 0;
|
|
114
|
+
if (state.follow)
|
|
115
|
+
state.unseen = 0;
|
|
116
|
+
};
|
|
117
|
+
function quit() {
|
|
118
|
+
if (!live)
|
|
119
|
+
return;
|
|
120
|
+
live = false;
|
|
121
|
+
stopInput();
|
|
122
|
+
stopResize();
|
|
123
|
+
if (spinTimer !== undefined)
|
|
124
|
+
clearInterval(spinTimer);
|
|
125
|
+
if (frameTimer !== undefined)
|
|
126
|
+
clearTimeout(frameTimer);
|
|
127
|
+
feedback.close();
|
|
128
|
+
terminal.leave();
|
|
129
|
+
closed?.();
|
|
130
|
+
}
|
|
131
|
+
function requestQuit() {
|
|
132
|
+
const activity = state.activity;
|
|
133
|
+
if (activity === undefined) {
|
|
134
|
+
quit();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
state.closeWhenIdle = true;
|
|
138
|
+
state.open = overlay.cancel(state.open);
|
|
139
|
+
activity.control.abort(new Error("interrupted"));
|
|
140
|
+
}
|
|
141
|
+
function startActivity(kind, label) {
|
|
142
|
+
if (state.activity !== undefined)
|
|
143
|
+
return undefined;
|
|
144
|
+
const activity = begin(kind, label);
|
|
145
|
+
state.activity = activity;
|
|
146
|
+
state.status = label;
|
|
147
|
+
spinTimer = setInterval(() => {
|
|
148
|
+
if (!session.config.reducedMotion)
|
|
149
|
+
state.spin++;
|
|
150
|
+
render();
|
|
151
|
+
}, session.config.reducedMotion ? 1_000 : SPIN_MS);
|
|
152
|
+
render();
|
|
153
|
+
return activity;
|
|
154
|
+
}
|
|
155
|
+
function finishActivity(activity) {
|
|
156
|
+
if (state.activity !== activity)
|
|
157
|
+
return;
|
|
158
|
+
if (spinTimer !== undefined)
|
|
159
|
+
clearInterval(spinTimer);
|
|
160
|
+
spinTimer = undefined;
|
|
161
|
+
state.activity = undefined;
|
|
162
|
+
state.status = undefined;
|
|
163
|
+
state.open = overlay.cancel(state.open);
|
|
164
|
+
if (state.closeWhenIdle)
|
|
165
|
+
quit();
|
|
166
|
+
else
|
|
167
|
+
render();
|
|
168
|
+
}
|
|
169
|
+
const actions = appWorkflows({
|
|
170
|
+
session,
|
|
171
|
+
transcriptRoot,
|
|
172
|
+
state,
|
|
173
|
+
allowed,
|
|
174
|
+
feedback,
|
|
175
|
+
emit,
|
|
176
|
+
commandOutput,
|
|
177
|
+
render,
|
|
178
|
+
refreshSettings: () => {
|
|
179
|
+
terminal.setReducedMotion(session.config.reducedMotion);
|
|
180
|
+
paint.invalidate();
|
|
181
|
+
render();
|
|
182
|
+
},
|
|
183
|
+
startActivity,
|
|
184
|
+
finishActivity,
|
|
185
|
+
});
|
|
186
|
+
const input = appInput({
|
|
187
|
+
session,
|
|
188
|
+
state,
|
|
189
|
+
feedback,
|
|
190
|
+
actions,
|
|
191
|
+
live: () => live,
|
|
192
|
+
quit,
|
|
193
|
+
requestQuit,
|
|
194
|
+
scrollBy,
|
|
195
|
+
invalidate: () => paint.invalidate(),
|
|
196
|
+
transcriptChanged: (block) => transcript.invalidate(block),
|
|
197
|
+
});
|
|
198
|
+
terminal.enter(session.config.reducedMotion);
|
|
199
|
+
stopResize = terminal.onResize(() => {
|
|
200
|
+
paint.invalidate();
|
|
201
|
+
draw();
|
|
202
|
+
});
|
|
203
|
+
stopInput = terminal.onInput((chunk) => {
|
|
204
|
+
for (const key of keys.push(chunk))
|
|
205
|
+
input.handle(key);
|
|
206
|
+
setTimeout(() => {
|
|
207
|
+
for (const key of keys.flush())
|
|
208
|
+
input.handle(key);
|
|
209
|
+
render();
|
|
210
|
+
}, ESCAPE_MS);
|
|
211
|
+
render();
|
|
212
|
+
});
|
|
213
|
+
draw();
|
|
214
|
+
await done;
|
|
215
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The permission question: what is being asked, and the answers on offer.
|
|
2
|
+
//
|
|
3
|
+
// A permission prompt is the one moment the agent hands control back, so it is
|
|
4
|
+
// worth more than a line of prose and a key to guess at. It is a menu — the
|
|
5
|
+
// generic one — with an attention title and three answers written out.
|
|
6
|
+
// Nothing is approved by a key nobody meant to press.
|
|
7
|
+
/**
|
|
8
|
+
* The narrow permission represented by "always".
|
|
9
|
+
*
|
|
10
|
+
* File-changing tools share a grant for one displayed path. Shell grants are
|
|
11
|
+
* tied to the exact command line. Unknown dangerous tools fall back to their
|
|
12
|
+
* exact, stable input rather than silently inheriting a tool-wide grant.
|
|
13
|
+
*/
|
|
14
|
+
export function scopeFor(call) {
|
|
15
|
+
const path = typeof call.input.path === "string" ? call.input.path : undefined;
|
|
16
|
+
if ((call.name === "write_file" || call.name === "edit_file") && path !== undefined) {
|
|
17
|
+
return { key: `file\0${path}`, label: `changes to ${path}`, summary: `file changes · ${path}` };
|
|
18
|
+
}
|
|
19
|
+
const command = typeof call.input.command === "string" ? call.input.command : undefined;
|
|
20
|
+
if (call.name === "run_command" && command !== undefined) {
|
|
21
|
+
return { key: `command\0${command}`, label: "this exact command", summary: `command · ${command}` };
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
key: `${call.name}\0${stable(call.input)}`,
|
|
25
|
+
label: "this exact call",
|
|
26
|
+
summary: `${call.name} · ${target(call.input)}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function promptFor(call, target, pal) {
|
|
30
|
+
const scope = scopeFor(call);
|
|
31
|
+
const options = [
|
|
32
|
+
{ label: `Run this ${call.name === "edit_file" ? "edit" : "call"} once`, hint: "enter", key: "y" },
|
|
33
|
+
{ label: `Allow ${scope.label} this session`, hint: "a", key: "a" },
|
|
34
|
+
{ label: "Deny and add feedback", hint: "esc", key: "n" },
|
|
35
|
+
];
|
|
36
|
+
return {
|
|
37
|
+
title: [
|
|
38
|
+
{ text: "Permission required", fg: pal.ink.attention, bold: true },
|
|
39
|
+
{ text: ` ${call.name}`, fg: pal.ink.muted },
|
|
40
|
+
...(target === "" ? [] : [{ text: ` ${target}`, fg: pal.ink.muted }]),
|
|
41
|
+
],
|
|
42
|
+
description: "Review the exact action above, then choose.",
|
|
43
|
+
footer: "Enter to select · Esc to deny",
|
|
44
|
+
options,
|
|
45
|
+
index: 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function stable(value) {
|
|
49
|
+
if (Array.isArray(value))
|
|
50
|
+
return `[${value.map(stable).join(",")}]`;
|
|
51
|
+
if (value !== null && typeof value === "object") {
|
|
52
|
+
return `{${Object.entries(value)
|
|
53
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
54
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`)
|
|
55
|
+
.join(",")}}`;
|
|
56
|
+
}
|
|
57
|
+
return JSON.stringify(value);
|
|
58
|
+
}
|
|
59
|
+
function target(input) {
|
|
60
|
+
const value = input.path ?? input.command;
|
|
61
|
+
return typeof value === "string" ? value : stable(input);
|
|
62
|
+
}
|
|
63
|
+
const ANSWERS = ["once", "always", "no"];
|
|
64
|
+
/** What picking row `index` meant. Anything unrecognised refuses. */
|
|
65
|
+
export function answerAt(index) {
|
|
66
|
+
return index === undefined ? "no" : (ANSWERS[index] ?? "no");
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Semantic transcript blocks routed to small, owned production components.
|
|
2
|
+
import { renderAnswer, renderReasoning, renderUser } from "./components/messages.js";
|
|
3
|
+
import { renderList, renderNotice } from "./components/misc.js";
|
|
4
|
+
import { renderTool } from "./components/tool.js";
|
|
5
|
+
export function render(block, width, pal) {
|
|
6
|
+
switch (block.kind) {
|
|
7
|
+
case "user":
|
|
8
|
+
return renderUser(block, width, pal);
|
|
9
|
+
case "answer":
|
|
10
|
+
return renderAnswer(block, width, pal);
|
|
11
|
+
case "reasoning":
|
|
12
|
+
return renderReasoning(block, width, pal);
|
|
13
|
+
case "tool":
|
|
14
|
+
return renderTool(block, width, pal);
|
|
15
|
+
case "notice":
|
|
16
|
+
return renderNotice(block, width, pal);
|
|
17
|
+
case "list":
|
|
18
|
+
return renderList(block, width, pal);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function renderAll(blocks, width, pal) {
|
|
22
|
+
return blocks.flatMap((block) => render(block, width, pal));
|
|
23
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Completing a slash command.
|
|
2
|
+
//
|
|
3
|
+
// A command the user cannot remember is a command that does not exist, and
|
|
4
|
+
// `/help` only helps someone who already knows to ask. So the menu appears
|
|
5
|
+
// while the line is being typed. Selection is state, not an edit: arrows move
|
|
6
|
+
// through the list without rewriting what the user typed.
|
|
7
|
+
import { COMMANDS } from "../commands.js";
|
|
8
|
+
/**
|
|
9
|
+
* The commands a half-typed line still matches.
|
|
10
|
+
*
|
|
11
|
+
* Empty once the line carries an argument: at that point the user is naming a
|
|
12
|
+
* model, not choosing a command, and a menu would be noise over the answer.
|
|
13
|
+
*/
|
|
14
|
+
export function matches(text) {
|
|
15
|
+
if (!text.startsWith("/"))
|
|
16
|
+
return [];
|
|
17
|
+
const typed = text.slice(1);
|
|
18
|
+
if (/\s/.test(typed))
|
|
19
|
+
return [];
|
|
20
|
+
return COMMANDS.filter((command) => command.name.startsWith(typed.toLowerCase()));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The nth match of `prefix`, or undefined when there is nothing to complete.
|
|
24
|
+
*
|
|
25
|
+
* The prefix is what the user typed, not what tab last wrote: completing `/`
|
|
26
|
+
* to `/help` would otherwise leave a line that matches only itself, and the
|
|
27
|
+
* second tab would have nowhere to go.
|
|
28
|
+
*/
|
|
29
|
+
export function pick(prefix, index) {
|
|
30
|
+
const list = matches(prefix);
|
|
31
|
+
if (list.length === 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const at = ((index % list.length) + list.length) % list.length;
|
|
34
|
+
return `/${list[at].name}`;
|
|
35
|
+
}
|
|
36
|
+
export function activate(text) {
|
|
37
|
+
return matches(text).length === 0 ? undefined : { prefix: text, index: 0 };
|
|
38
|
+
}
|
|
39
|
+
export function move(completion, step) {
|
|
40
|
+
const count = matches(completion.prefix).length;
|
|
41
|
+
if (count === 0)
|
|
42
|
+
return completion;
|
|
43
|
+
return {
|
|
44
|
+
...completion,
|
|
45
|
+
index: ((completion.index + step) % count + count) % count,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function selected(completion) {
|
|
49
|
+
return pick(completion.prefix, completion.index);
|
|
50
|
+
}
|
|
51
|
+
/** Suggestions stay visible only while completion mode is active. */
|
|
52
|
+
export function options(completion) {
|
|
53
|
+
return completion === undefined ? [] : matches(completion.prefix);
|
|
54
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { menuWindow, renderMenuRows } from "./menu.js";
|
|
2
|
+
const MAX_ROWS = 4;
|
|
3
|
+
export function renderCommandMenu(commands, selected, width, pal) {
|
|
4
|
+
const selectedIndex = selected === undefined ? 0 : Math.max(0, Math.min(commands.length - 1, selected));
|
|
5
|
+
const { first, last } = menuWindow(commands.length, selectedIndex, MAX_ROWS);
|
|
6
|
+
const shown = commands.slice(first, last);
|
|
7
|
+
if (shown.length === 0)
|
|
8
|
+
return [];
|
|
9
|
+
return renderMenuRows(shown.map((command, index) => ({
|
|
10
|
+
label: `/${command.name}`,
|
|
11
|
+
description: command.blurb,
|
|
12
|
+
selected: selectedIndex === first + index,
|
|
13
|
+
})), width, pal);
|
|
14
|
+
}
|
|
15
|
+
export function commandMenuLimit() {
|
|
16
|
+
return MAX_ROWS;
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { row } from "../../ui/render.js";
|
|
2
|
+
import { charWidth, graphemes } from "../../ui/width.js";
|
|
3
|
+
export function renderComposer(editor, width, maxInputRows, pal) {
|
|
4
|
+
const laid = layout(editor, Math.max(1, width));
|
|
5
|
+
const room = Math.max(1, maxInputRows);
|
|
6
|
+
const first = Math.max(0, Math.min(laid.lines.length - room, laid.cursor.row));
|
|
7
|
+
const shown = laid.lines.slice(first, first + room);
|
|
8
|
+
return {
|
|
9
|
+
rows: shown.map((line) => row(width, [{ text: line, fg: pal.ink.bright }])),
|
|
10
|
+
cursor: { row: laid.cursor.row - first, col: laid.cursor.col },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function layout(editor, width) {
|
|
14
|
+
const lines = [];
|
|
15
|
+
let line = "";
|
|
16
|
+
let used = 0;
|
|
17
|
+
let index = 0;
|
|
18
|
+
let cursor = { row: 0, col: 0 };
|
|
19
|
+
const place = () => {
|
|
20
|
+
cursor = { row: lines.length, col: used };
|
|
21
|
+
};
|
|
22
|
+
const feed = () => {
|
|
23
|
+
lines.push(line);
|
|
24
|
+
line = "";
|
|
25
|
+
used = 0;
|
|
26
|
+
};
|
|
27
|
+
for (const cluster of graphemes(editor.text)) {
|
|
28
|
+
if (index === editor.cursor)
|
|
29
|
+
place();
|
|
30
|
+
index += cluster.length;
|
|
31
|
+
if (cluster === "\n") {
|
|
32
|
+
feed();
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const cells = charWidth(cluster);
|
|
36
|
+
if (used + cells > width)
|
|
37
|
+
feed();
|
|
38
|
+
line += cluster;
|
|
39
|
+
used += cells;
|
|
40
|
+
if (used >= width)
|
|
41
|
+
feed();
|
|
42
|
+
}
|
|
43
|
+
if (index === editor.cursor)
|
|
44
|
+
place();
|
|
45
|
+
lines.push(line);
|
|
46
|
+
return { lines, cursor };
|
|
47
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The one shell shared by every interaction at the bottom of the screen.
|
|
2
|
+
import { rule } from "../../ui/render.js";
|
|
3
|
+
export function renderDock(body, width, pal, active) {
|
|
4
|
+
return {
|
|
5
|
+
rows: [rule(width, active ? pal.focus : pal.rule), ...body.rows, rule(width, active ? pal.focus : pal.rule)],
|
|
6
|
+
cursor: body.cursor === undefined
|
|
7
|
+
? undefined
|
|
8
|
+
: { row: body.cursor.row + 1, col: body.cursor.col },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { fitSegs, plainLen, row } from "../../ui/render.js";
|
|
2
|
+
import { elide, textWidth } from "../../ui/width.js";
|
|
3
|
+
export function renderFooter(info, status, width, pal) {
|
|
4
|
+
const rightLimit = Math.min(width, Math.max(12, Math.floor(width * 0.58)));
|
|
5
|
+
const right = fitSegs(status, rightLimit);
|
|
6
|
+
const leftRoom = Math.max(0, width - plainLen(right) - (right.length === 0 ? 0 : 1));
|
|
7
|
+
const left = identity(info, leftRoom);
|
|
8
|
+
return [row(width, left === "" ? [] : [{ text: left, fg: pal.ink.muted }], right)];
|
|
9
|
+
}
|
|
10
|
+
function identity(info, cols) {
|
|
11
|
+
if (cols <= 0)
|
|
12
|
+
return "";
|
|
13
|
+
const core = `${info.model || "no model"} · ${info.effort}`;
|
|
14
|
+
if (textWidth(core) >= cols || info.workspace === "")
|
|
15
|
+
return elide(core, cols);
|
|
16
|
+
const divider = " · ";
|
|
17
|
+
const workspaceRoom = cols - textWidth(core) - textWidth(divider);
|
|
18
|
+
return workspaceRoom <= 0
|
|
19
|
+
? core
|
|
20
|
+
: `${core}${divider}${elide(info.workspace, workspaceRoom)}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// One visual row grammar for autocomplete and every interactive selector.
|
|
2
|
+
import { plainLen, row } from "../../ui/render.js";
|
|
3
|
+
import { elide } from "../../ui/width.js";
|
|
4
|
+
export function renderMenuRows(entries, width, pal) {
|
|
5
|
+
if (entries.length === 0)
|
|
6
|
+
return [];
|
|
7
|
+
const widest = Math.max(...entries.map(primaryWidth));
|
|
8
|
+
const labelWidth = Math.min(42, Math.max(12, widest + 2));
|
|
9
|
+
return entries.map((entry) => renderEntry(entry, labelWidth, width, pal));
|
|
10
|
+
}
|
|
11
|
+
export function menuWindow(length, selected, visible) {
|
|
12
|
+
const count = Math.max(0, length);
|
|
13
|
+
const room = Math.max(1, visible);
|
|
14
|
+
const at = count === 0 ? 0 : Math.max(0, Math.min(count - 1, selected));
|
|
15
|
+
const first = Math.max(0, Math.min(at - Math.floor(room / 2), count - room));
|
|
16
|
+
return { first, last: Math.min(count, first + room) };
|
|
17
|
+
}
|
|
18
|
+
function renderEntry(entry, labelWidth, width, pal) {
|
|
19
|
+
const fg = entry.selected ? pal.accent : pal.ink.fg;
|
|
20
|
+
const primary = [
|
|
21
|
+
{ text: entry.selected ? "→ " : " ", fg },
|
|
22
|
+
{ text: entry.label, fg },
|
|
23
|
+
];
|
|
24
|
+
if (width > 40 && entry.description !== undefined) {
|
|
25
|
+
const gap = Math.max(2, labelWidth - primaryWidth(entry));
|
|
26
|
+
primary.push({ text: `${" ".repeat(gap)}${entry.description}`, fg: entry.selected ? pal.accent : pal.ink.muted });
|
|
27
|
+
}
|
|
28
|
+
const right = width > 40 && entry.hint !== undefined
|
|
29
|
+
? [{
|
|
30
|
+
text: elide(entry.hint, Math.max(1, Math.floor(width / 4))),
|
|
31
|
+
fg: entry.selected ? pal.accent : pal.ink.muted,
|
|
32
|
+
}]
|
|
33
|
+
: [];
|
|
34
|
+
return row(width, primary, right);
|
|
35
|
+
}
|
|
36
|
+
function primaryWidth(entry) {
|
|
37
|
+
return plainLen([{ text: entry.label }]);
|
|
38
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { blank, row } from "../../ui/render.js";
|
|
2
|
+
import { markdown } from "../../ui/markdown.js";
|
|
3
|
+
const PAD = 1;
|
|
4
|
+
export const REASONING_PREVIEW_ROWS = 3;
|
|
5
|
+
export function renderUser(block, width, pal) {
|
|
6
|
+
const inner = Math.max(8, width - PAD * 2);
|
|
7
|
+
const content = markdown(block.text, inner, pal, inner);
|
|
8
|
+
return [
|
|
9
|
+
"",
|
|
10
|
+
blank(width, pal.surface.subtle),
|
|
11
|
+
...content.map((line) => row(width, line.segs, [], pal.surface.subtle, PAD)),
|
|
12
|
+
blank(width, pal.surface.subtle),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
export function renderAnswer(block, width, pal) {
|
|
16
|
+
const inner = Math.max(8, width - PAD * 2);
|
|
17
|
+
return [
|
|
18
|
+
"",
|
|
19
|
+
...markdown(block.text, inner, pal, inner).map((line) => row(width, line.segs, [], undefined, PAD)),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
export function renderReasoning(block, width, pal) {
|
|
23
|
+
const inner = Math.max(8, width - PAD * 2);
|
|
24
|
+
const content = markdown(block.text, inner, pal, inner);
|
|
25
|
+
const expanded = block.expanded === true;
|
|
26
|
+
const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
|
|
27
|
+
const action = expanded
|
|
28
|
+
? "ctrl+o compact"
|
|
29
|
+
: content.length > REASONING_PREVIEW_ROWS
|
|
30
|
+
? "ctrl+o full"
|
|
31
|
+
: undefined;
|
|
32
|
+
return [
|
|
33
|
+
"",
|
|
34
|
+
row(width, [
|
|
35
|
+
{
|
|
36
|
+
text: block.live === true ? "thinking" : "thought",
|
|
37
|
+
fg: pal.ink.bright,
|
|
38
|
+
bold: true,
|
|
39
|
+
},
|
|
40
|
+
], action === undefined ? [] : [{ text: action, fg: pal.ink.muted }], undefined, PAD),
|
|
41
|
+
...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.muted, italic: true })), [], undefined, PAD)),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { row, wrap } from "../../ui/render.js";
|
|
2
|
+
export function renderNotice(block, width, pal) {
|
|
3
|
+
const fg = {
|
|
4
|
+
info: pal.ink.muted,
|
|
5
|
+
warn: pal.ink.attention,
|
|
6
|
+
error: pal.ink.removed,
|
|
7
|
+
};
|
|
8
|
+
const mark = block.tone === "error" ? "× " : block.tone === "warn" ? "! " : "· ";
|
|
9
|
+
return [
|
|
10
|
+
"",
|
|
11
|
+
...wrap(block.text, Math.max(1, width - 3)).map((line, index) => row(width, [
|
|
12
|
+
{ text: index === 0 ? mark : " ", fg: fg[block.tone], bold: index === 0 },
|
|
13
|
+
{ text: line, fg: fg[block.tone] },
|
|
14
|
+
], [], undefined, 1)),
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
export function renderList(block, width, pal) {
|
|
18
|
+
return [
|
|
19
|
+
"",
|
|
20
|
+
...block.items.map((item) => row(width, [{ text: item.text, fg: item.dim ? pal.ink.muted : pal.ink.fg }], [], undefined, 1)),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
export function spinner(tick) {
|
|
3
|
+
return SPINNER[tick % SPINNER.length];
|
|
4
|
+
}
|
|
5
|
+
/** Styled content for the footer's replaceable right-hand status channel. */
|
|
6
|
+
export function renderStatus(info, pal) {
|
|
7
|
+
const urgent = info.feedback?.tone === "warn" || info.feedback?.tone === "error"
|
|
8
|
+
? info.feedback
|
|
9
|
+
: undefined;
|
|
10
|
+
if (urgent !== undefined)
|
|
11
|
+
return withUnseen(feedbackSegments(urgent, pal), info.unseen, pal);
|
|
12
|
+
if (info.status !== undefined) {
|
|
13
|
+
return withUnseen([
|
|
14
|
+
{ text: `${info.reducedMotion ? "•" : spinner(info.tick)} `, fg: pal.accent },
|
|
15
|
+
{ text: `${info.status} (esc to interrupt)`, fg: pal.ink.muted },
|
|
16
|
+
], info.unseen, pal);
|
|
17
|
+
}
|
|
18
|
+
if (info.feedback !== undefined) {
|
|
19
|
+
return withUnseen(feedbackSegments(info.feedback, pal), info.unseen, pal);
|
|
20
|
+
}
|
|
21
|
+
if (info.unseen > 0)
|
|
22
|
+
return unseenSegments(info.unseen, pal);
|
|
23
|
+
return info.readiness === undefined ? [] : feedbackSegments(info.readiness, pal);
|
|
24
|
+
}
|
|
25
|
+
function feedbackSegments(feedback, pal) {
|
|
26
|
+
const mark = feedback.tone === "error" ? "×" : feedback.tone === "warn" ? "!" : "·";
|
|
27
|
+
const markColor = {
|
|
28
|
+
info: pal.accent,
|
|
29
|
+
warn: pal.ink.attention,
|
|
30
|
+
error: pal.ink.removed,
|
|
31
|
+
};
|
|
32
|
+
const textColor = feedback.tone === "error" ? pal.ink.removed : pal.ink.muted;
|
|
33
|
+
return [
|
|
34
|
+
{ text: `${mark} `, fg: markColor[feedback.tone], bold: true },
|
|
35
|
+
{ text: feedback.text, fg: textColor },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
function withUnseen(status, unseen, pal) {
|
|
39
|
+
return unseen === 0
|
|
40
|
+
? status
|
|
41
|
+
: [...status, { text: " · ", fg: pal.ink.muted }, ...unseenSegments(unseen, pal)];
|
|
42
|
+
}
|
|
43
|
+
function unseenSegments(unseen, pal) {
|
|
44
|
+
return [{ text: `${unseen} new ↓`, fg: pal.accent, bold: true }];
|
|
45
|
+
}
|