mini-coder 0.4.1 → 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 +87 -48
- package/assets/icon-1-minimal.svg +31 -0
- package/assets/icon-2-dark-terminal.svg +48 -0
- package/assets/icon-3-gradient-modern.svg +45 -0
- package/assets/icon-4-filled-bold.svg +54 -0
- package/assets/icon-5-community-badge.svg +63 -0
- package/assets/preview-0-5-0.png +0 -0
- package/assets/preview.gif +0 -0
- package/bin/mc.ts +14 -0
- package/bun.lock +438 -0
- package/package.json +12 -29
- package/src/agent.ts +592 -0
- package/src/cli.ts +124 -0
- package/src/git.ts +164 -0
- package/src/headless.ts +140 -0
- package/src/index.ts +645 -0
- package/src/input.ts +155 -0
- package/src/paths.ts +37 -0
- package/src/plugins.ts +183 -0
- package/src/prompt.ts +294 -0
- package/src/session.ts +838 -0
- package/src/settings.ts +184 -0
- package/src/skills.ts +258 -0
- package/src/submit.ts +323 -0
- package/src/theme.ts +147 -0
- package/src/tools.ts +636 -0
- package/src/ui/agent.test.ts +49 -0
- package/src/ui/agent.ts +210 -0
- package/src/ui/commands.test.ts +610 -0
- package/src/ui/commands.ts +638 -0
- package/src/ui/conversation.test.ts +892 -0
- package/src/ui/conversation.ts +926 -0
- package/src/ui/help.test.ts +26 -0
- package/src/ui/help.ts +119 -0
- package/src/ui/input.test.ts +74 -0
- package/src/ui/input.ts +138 -0
- package/src/ui/overlay.test.ts +42 -0
- package/src/ui/overlay.ts +59 -0
- package/src/ui/status.test.ts +450 -0
- package/src/ui/status.ts +357 -0
- package/src/ui.ts +615 -0
- package/.claude/settings.local.json +0 -54
- package/.prettierignore +0 -7
- package/dist/mc-edit.js +0 -275
- package/dist/mc.js +0 -7355
- package/docs/KNOWN_ISSUES.md +0 -13
- package/docs/design-decisions.md +0 -31
- package/docs/mini-coder.1.md +0 -227
- package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
- package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
- package/lefthook.yml +0 -4
package/src/ui.ts
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI for mini-coder.
|
|
3
|
+
*
|
|
4
|
+
* Owns the cel-tui lifecycle (init/stop), renders the conversation log,
|
|
5
|
+
* input area, animated divider, and status bar. Wires user input to the
|
|
6
|
+
* agent loop and streams events back to the UI.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { exec } from "node:child_process";
|
|
12
|
+
import { platform } from "node:os";
|
|
13
|
+
import {
|
|
14
|
+
cel,
|
|
15
|
+
HStack,
|
|
16
|
+
measureContentHeight,
|
|
17
|
+
ProcessTerminal,
|
|
18
|
+
Text,
|
|
19
|
+
VStack,
|
|
20
|
+
} from "@cel-tui/core";
|
|
21
|
+
import type { Node } from "@cel-tui/types";
|
|
22
|
+
import type { AppState } from "./index.ts";
|
|
23
|
+
import { reloadPromptContext, shutdown } from "./index.ts";
|
|
24
|
+
import { appendMessage, createUiMessage } from "./session.ts";
|
|
25
|
+
import type { Theme } from "./theme.ts";
|
|
26
|
+
import {
|
|
27
|
+
createUiAgentController,
|
|
28
|
+
getStreamingConversationState,
|
|
29
|
+
resetUiAgentState,
|
|
30
|
+
} from "./ui/agent.ts";
|
|
31
|
+
import { createCommandController } from "./ui/commands.ts";
|
|
32
|
+
import {
|
|
33
|
+
buildConversationLogNodes,
|
|
34
|
+
CONVERSATION_GAP,
|
|
35
|
+
resetConversationRenderCache,
|
|
36
|
+
} from "./ui/conversation.ts";
|
|
37
|
+
import type { InputController } from "./ui/input.ts";
|
|
38
|
+
import {
|
|
39
|
+
autocompleteInputPath,
|
|
40
|
+
renderInputArea as renderInputAreaNode,
|
|
41
|
+
} from "./ui/input.ts";
|
|
42
|
+
import { type ActiveOverlay, renderOverlay } from "./ui/overlay.ts";
|
|
43
|
+
import { renderStatusBar } from "./ui/status.ts";
|
|
44
|
+
|
|
45
|
+
export type { InputController } from "./ui/input.ts";
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Constants
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** Divider animation speed (ms per frame). */
|
|
52
|
+
const DIVIDER_FRAME_MS = 60;
|
|
53
|
+
|
|
54
|
+
/** Width of the bright pulse segment in the animated divider. */
|
|
55
|
+
const PULSE_WIDTH = 5;
|
|
56
|
+
|
|
57
|
+
/** Maximum number of committed messages rendered before older history is chunked. */
|
|
58
|
+
const CONVERSATION_CHUNK_MESSAGES = 50;
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// UI state (module-scoped, not in AppState)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Scroll position for the conversation log. */
|
|
65
|
+
let scrollOffset = 0;
|
|
66
|
+
|
|
67
|
+
/** Whether the log auto-scrolls to the bottom. */
|
|
68
|
+
let stickToBottom = true;
|
|
69
|
+
|
|
70
|
+
/** First visible committed message when older history is chunked. */
|
|
71
|
+
let visibleConversationStart = 0;
|
|
72
|
+
|
|
73
|
+
/** Current text in the input area. */
|
|
74
|
+
let inputValue = "";
|
|
75
|
+
|
|
76
|
+
/** Whether the text input is focused. */
|
|
77
|
+
let inputFocused = true;
|
|
78
|
+
|
|
79
|
+
/** Animated divider frame counter. */
|
|
80
|
+
let dividerTick = 0;
|
|
81
|
+
|
|
82
|
+
/** Divider animation timer handle. */
|
|
83
|
+
let dividerTimer: ReturnType<typeof setInterval> | null = null;
|
|
84
|
+
|
|
85
|
+
/** Whether stdin was already in raw mode before the TUI initialized. */
|
|
86
|
+
let stdinWasRaw = false;
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Overlay state
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/** Active overlay for interactive commands (/model, /effort, etc.). */
|
|
93
|
+
let activeOverlay: ActiveOverlay | null = null;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reset all module-scoped UI state.
|
|
97
|
+
*
|
|
98
|
+
* Useful for reinitializing the UI and for keeping tests isolated.
|
|
99
|
+
*/
|
|
100
|
+
export function resetUiState(): void {
|
|
101
|
+
scrollOffset = 0;
|
|
102
|
+
stickToBottom = true;
|
|
103
|
+
visibleConversationStart = 0;
|
|
104
|
+
inputValue = "";
|
|
105
|
+
inputFocused = true;
|
|
106
|
+
dividerTick = 0;
|
|
107
|
+
stopDividerAnimation();
|
|
108
|
+
resetUiAgentState();
|
|
109
|
+
resetConversationRenderCache();
|
|
110
|
+
activeOverlay = null;
|
|
111
|
+
stdinWasRaw = false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Divider animation
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/** Start the scanning pulse animation on the divider. */
|
|
119
|
+
function startDividerAnimation(): void {
|
|
120
|
+
stopDividerAnimation();
|
|
121
|
+
dividerTick = 0;
|
|
122
|
+
dividerTimer = setInterval(() => {
|
|
123
|
+
dividerTick++;
|
|
124
|
+
cel.render();
|
|
125
|
+
}, DIVIDER_FRAME_MS);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Stop the divider animation. */
|
|
129
|
+
function stopDividerAnimation(): void {
|
|
130
|
+
if (dividerTimer) {
|
|
131
|
+
clearInterval(dividerTimer);
|
|
132
|
+
dividerTimer = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Render the animated divider.
|
|
138
|
+
*
|
|
139
|
+
* When the agent is working, a bright segment sweeps across the dimmed
|
|
140
|
+
* line. When idle, it's a static dimmed line.
|
|
141
|
+
*/
|
|
142
|
+
function renderDivider(state: AppState, width: number): Node {
|
|
143
|
+
if (!state.running) {
|
|
144
|
+
return Text("─", { repeat: "fill", fgColor: state.theme.divider });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const total = Math.max(width, 1);
|
|
148
|
+
const pos = dividerTick % (total + PULSE_WIDTH);
|
|
149
|
+
const pulseStart = Math.max(0, pos - PULSE_WIDTH);
|
|
150
|
+
const pulseEnd = Math.min(pos, total);
|
|
151
|
+
const pulseLen = pulseEnd - pulseStart;
|
|
152
|
+
const beforeLen = pulseStart;
|
|
153
|
+
const afterLen = total - pulseEnd;
|
|
154
|
+
|
|
155
|
+
const segments: Node[] = [];
|
|
156
|
+
if (beforeLen > 0) {
|
|
157
|
+
segments.push(
|
|
158
|
+
Text("─", { repeat: beforeLen, fgColor: state.theme.divider }),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (pulseLen > 0) {
|
|
162
|
+
segments.push(
|
|
163
|
+
Text("═", {
|
|
164
|
+
repeat: pulseLen,
|
|
165
|
+
fgColor: state.theme.dividerPulse,
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (afterLen > 0) {
|
|
170
|
+
segments.push(
|
|
171
|
+
Text("─", { repeat: afterLen, fgColor: state.theme.divider }),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return HStack({ height: 1 }, segments);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Conversation log
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function getLatestConversationChunkStart(messageCount: number): number {
|
|
183
|
+
return Math.max(0, messageCount - CONVERSATION_CHUNK_MESSAGES);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getVisibleConversationStart(messageCount: number): number {
|
|
187
|
+
if (stickToBottom) {
|
|
188
|
+
return getLatestConversationChunkStart(messageCount);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
visibleConversationStart = Math.min(
|
|
192
|
+
visibleConversationStart,
|
|
193
|
+
getLatestConversationChunkStart(messageCount),
|
|
194
|
+
);
|
|
195
|
+
return visibleConversationStart;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Build the full conversation log as an array of nodes. */
|
|
199
|
+
export function buildConversationLog(
|
|
200
|
+
state: AppState,
|
|
201
|
+
width = Number.POSITIVE_INFINITY,
|
|
202
|
+
): Node[] {
|
|
203
|
+
return buildConversationLogNodes(
|
|
204
|
+
state,
|
|
205
|
+
getStreamingConversationState(),
|
|
206
|
+
getVisibleConversationStart(state.messages.length),
|
|
207
|
+
width,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function measureConversationHeight(
|
|
212
|
+
state: AppState,
|
|
213
|
+
width: number,
|
|
214
|
+
startIndex: number,
|
|
215
|
+
): number {
|
|
216
|
+
return measureContentHeight(
|
|
217
|
+
VStack(
|
|
218
|
+
{ gap: CONVERSATION_GAP },
|
|
219
|
+
buildConversationLogNodes(
|
|
220
|
+
state,
|
|
221
|
+
getStreamingConversationState(),
|
|
222
|
+
startIndex,
|
|
223
|
+
width,
|
|
224
|
+
),
|
|
225
|
+
),
|
|
226
|
+
{ width: Math.max(1, width) },
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function prependConversationChunk(state: AppState, width: number): void {
|
|
231
|
+
const currentStart = visibleConversationStart;
|
|
232
|
+
const nextStart = Math.max(0, currentStart - CONVERSATION_CHUNK_MESSAGES);
|
|
233
|
+
const currentHeight = measureConversationHeight(state, width, currentStart);
|
|
234
|
+
const nextHeight = measureConversationHeight(state, width, nextStart);
|
|
235
|
+
|
|
236
|
+
visibleConversationStart = nextStart;
|
|
237
|
+
scrollOffset += Math.max(0, nextHeight - currentHeight);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Overlay rendering
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/** Open an overlay and move focus away from the input. */
|
|
245
|
+
function openOverlay(overlay: ActiveOverlay): void {
|
|
246
|
+
activeOverlay = overlay;
|
|
247
|
+
inputFocused = false;
|
|
248
|
+
cel.render();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Dismiss the active overlay and return focus to the input. */
|
|
252
|
+
function dismissOverlay(): void {
|
|
253
|
+
activeOverlay = null;
|
|
254
|
+
inputFocused = true;
|
|
255
|
+
cel.render();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Render the active overlay when one is open.
|
|
260
|
+
*
|
|
261
|
+
* @param state - Application state.
|
|
262
|
+
* @returns The rendered overlay node, or `null` when no overlay is active.
|
|
263
|
+
*/
|
|
264
|
+
export function renderActiveOverlay(state: AppState): Node | null {
|
|
265
|
+
if (!activeOverlay) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
return renderOverlay(state.theme, activeOverlay);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Input area
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Create stable handlers for the main TextInput.
|
|
277
|
+
*
|
|
278
|
+
* cel-tui keys TextInput cursor/scroll state by the `onChange` function
|
|
279
|
+
* reference, so these callbacks must be created once and reused across
|
|
280
|
+
* renders.
|
|
281
|
+
*
|
|
282
|
+
* @param state - Application state used by the handlers.
|
|
283
|
+
* @returns Stable callbacks for the controlled TextInput.
|
|
284
|
+
*/
|
|
285
|
+
export function createInputController(state: AppState): InputController {
|
|
286
|
+
return {
|
|
287
|
+
onChange: (value) => {
|
|
288
|
+
inputValue = value;
|
|
289
|
+
cel.render();
|
|
290
|
+
},
|
|
291
|
+
onFocus: () => {
|
|
292
|
+
inputFocused = true;
|
|
293
|
+
cel.render();
|
|
294
|
+
},
|
|
295
|
+
onBlur: () => {
|
|
296
|
+
inputFocused = false;
|
|
297
|
+
cel.render();
|
|
298
|
+
},
|
|
299
|
+
onKeyPress: (key) => {
|
|
300
|
+
if (key === "enter") {
|
|
301
|
+
const raw = inputValue;
|
|
302
|
+
inputValue = "";
|
|
303
|
+
cel.render();
|
|
304
|
+
handleInput(raw, state);
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (key === "tab") {
|
|
308
|
+
if (inputValue.startsWith("/")) {
|
|
309
|
+
commandController.showCommandAutocomplete(state);
|
|
310
|
+
} else {
|
|
311
|
+
const completedInput = autocompleteInputPath(inputValue, state.cwd);
|
|
312
|
+
if (completedInput) {
|
|
313
|
+
inputValue = completedInput;
|
|
314
|
+
cel.render();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Render the input area.
|
|
325
|
+
*
|
|
326
|
+
* @param theme - Active UI theme.
|
|
327
|
+
* @param controller - Stable TextInput callbacks.
|
|
328
|
+
* @returns The input area node.
|
|
329
|
+
*/
|
|
330
|
+
export function renderInputArea(
|
|
331
|
+
theme: Theme,
|
|
332
|
+
controller: InputController,
|
|
333
|
+
): Node {
|
|
334
|
+
return renderInputAreaNode(theme, controller, inputValue, inputFocused);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Runtime helpers and controllers
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/** Open a URL in the user's default browser. */
|
|
342
|
+
function openInBrowser(url: string): void {
|
|
343
|
+
const cmd = platform() === "darwin" ? "open" : "xdg-open";
|
|
344
|
+
exec(`${cmd} ${JSON.stringify(url)}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function scrollConversationToBottom(): void {
|
|
348
|
+
stickToBottom = true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Append a UI-only info message to the conversation log.
|
|
353
|
+
*
|
|
354
|
+
* When no persisted session exists yet, the message stays in memory and is
|
|
355
|
+
* backfilled if the user later starts a session by sending a message.
|
|
356
|
+
*
|
|
357
|
+
* @param text - Display text to append.
|
|
358
|
+
* @param state - Application state.
|
|
359
|
+
*/
|
|
360
|
+
function appendInfoMessage(text: string, state: AppState): void {
|
|
361
|
+
const msg = createUiMessage(text);
|
|
362
|
+
if (state.session) {
|
|
363
|
+
appendMessage(state.db, state.session.id, msg);
|
|
364
|
+
}
|
|
365
|
+
state.messages.push(msg);
|
|
366
|
+
scrollConversationToBottom();
|
|
367
|
+
cel.render();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Command controller bound to the module-scoped UI runtime hooks. */
|
|
371
|
+
const commandController = createCommandController({
|
|
372
|
+
openOverlay,
|
|
373
|
+
dismissOverlay,
|
|
374
|
+
setInputValue: (value) => {
|
|
375
|
+
inputValue = value;
|
|
376
|
+
},
|
|
377
|
+
appendInfoMessage,
|
|
378
|
+
scrollConversationToBottom,
|
|
379
|
+
render: () => {
|
|
380
|
+
cel.render();
|
|
381
|
+
},
|
|
382
|
+
reloadPromptContext,
|
|
383
|
+
openInBrowser,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Agent loop wiring
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
/** Agent controller bound to the module-scoped UI runtime hooks. */
|
|
391
|
+
const agentController = createUiAgentController({
|
|
392
|
+
appendInfoMessage,
|
|
393
|
+
handleCommand: (command, state) =>
|
|
394
|
+
commandController.handleCommand(command, state),
|
|
395
|
+
render: () => {
|
|
396
|
+
cel.render();
|
|
397
|
+
},
|
|
398
|
+
scrollConversationToBottom,
|
|
399
|
+
startDividerAnimation,
|
|
400
|
+
stopDividerAnimation,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
/** Route raw user input through parseInput and dispatch accordingly. */
|
|
404
|
+
export function handleInput(raw: string, state: AppState): void {
|
|
405
|
+
agentController.handleInput(raw, state);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Graceful exit
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
/** Shut down cleanly and exit. */
|
|
413
|
+
async function gracefulExit(state: AppState): Promise<void> {
|
|
414
|
+
stopDividerAnimation();
|
|
415
|
+
if (state.abortController) state.abortController.abort();
|
|
416
|
+
cel.stop();
|
|
417
|
+
await shutdown(state);
|
|
418
|
+
process.exit(0);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Restore the terminal to the shell before suspending. */
|
|
422
|
+
function suspendTerminalUi(): void {
|
|
423
|
+
stopDividerAnimation();
|
|
424
|
+
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
|
425
|
+
process.stdout.write("\x1b[<u");
|
|
426
|
+
process.stdout.write("\x1b[?25h");
|
|
427
|
+
process.stdout.write("\x1b[?1049l");
|
|
428
|
+
process.stdin.pause();
|
|
429
|
+
if (process.stdin.setRawMode) {
|
|
430
|
+
process.stdin.setRawMode(stdinWasRaw);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Re-enter the TUI terminal modes after a suspended process is resumed. */
|
|
435
|
+
function resumeTerminalUi(): void {
|
|
436
|
+
if (process.stdin.setRawMode) {
|
|
437
|
+
process.stdin.setRawMode(true);
|
|
438
|
+
}
|
|
439
|
+
process.stdin.resume();
|
|
440
|
+
process.stdout.write("\x1b[?1049h");
|
|
441
|
+
process.stdout.write("\x1b[>1u");
|
|
442
|
+
process.stdout.write("\x1b[?1000h\x1b[?1006h");
|
|
443
|
+
process.stdout.write("\x1b[?25l");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Suspend the app to the background and restore the UI on SIGCONT. */
|
|
447
|
+
export function suspendToBackground(
|
|
448
|
+
resumeUi: () => void,
|
|
449
|
+
runtime?: {
|
|
450
|
+
stop?: () => void;
|
|
451
|
+
onResume?: (resume: () => void) => void;
|
|
452
|
+
suspend?: () => void;
|
|
453
|
+
},
|
|
454
|
+
): void {
|
|
455
|
+
const stop = runtime?.stop ?? suspendTerminalUi;
|
|
456
|
+
const onResume =
|
|
457
|
+
runtime?.onResume ??
|
|
458
|
+
((resume: () => void) => {
|
|
459
|
+
process.once("SIGCONT", resume);
|
|
460
|
+
});
|
|
461
|
+
const suspend =
|
|
462
|
+
runtime?.suspend ??
|
|
463
|
+
(() => {
|
|
464
|
+
process.kill(process.pid, "SIGTSTP");
|
|
465
|
+
});
|
|
466
|
+
const keepAlive = setInterval(() => {}, 1 << 30);
|
|
467
|
+
|
|
468
|
+
stop();
|
|
469
|
+
onResume(() => {
|
|
470
|
+
clearInterval(keepAlive);
|
|
471
|
+
resumeUi();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
suspend();
|
|
476
|
+
} catch (error) {
|
|
477
|
+
clearInterval(keepAlive);
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// Main
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
function renderConversationLog(state: AppState, width: number): Node {
|
|
487
|
+
return VStack(
|
|
488
|
+
{
|
|
489
|
+
flex: 1,
|
|
490
|
+
gap: CONVERSATION_GAP,
|
|
491
|
+
overflow: "scroll",
|
|
492
|
+
scrollbar: true,
|
|
493
|
+
scrollOffset: stickToBottom ? Infinity : scrollOffset,
|
|
494
|
+
onScroll: (offset, maxOffset) => {
|
|
495
|
+
const wasStickToBottom = stickToBottom;
|
|
496
|
+
scrollOffset = offset;
|
|
497
|
+
|
|
498
|
+
if (wasStickToBottom && offset < maxOffset) {
|
|
499
|
+
visibleConversationStart = getLatestConversationChunkStart(
|
|
500
|
+
state.messages.length,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
stickToBottom = offset >= maxOffset;
|
|
505
|
+
|
|
506
|
+
if (!stickToBottom && offset === 0 && visibleConversationStart > 0) {
|
|
507
|
+
prependConversationChunk(state, width);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
cel.render();
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
buildConversationLog(state, width),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Render the base application layout without overlays.
|
|
519
|
+
*
|
|
520
|
+
* The layout contains the conversation log, the animated divider, the input
|
|
521
|
+
* area, and the one-line pill-based status bar.
|
|
522
|
+
*
|
|
523
|
+
* @param state - Application state.
|
|
524
|
+
* @param cols - Current terminal width in columns.
|
|
525
|
+
* @param inputController - Stable callbacks for the controlled TextInput.
|
|
526
|
+
* @returns The base layout node.
|
|
527
|
+
*/
|
|
528
|
+
export function renderBaseLayout(
|
|
529
|
+
state: AppState,
|
|
530
|
+
cols: number,
|
|
531
|
+
inputController: InputController,
|
|
532
|
+
onSuspend?: () => void,
|
|
533
|
+
): Node {
|
|
534
|
+
return VStack(
|
|
535
|
+
{
|
|
536
|
+
height: "100%",
|
|
537
|
+
onKeyPress: (key) => {
|
|
538
|
+
if (key === "ctrl+r") {
|
|
539
|
+
commandController.showInputHistoryOverlay(state);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (key === "ctrl+c") {
|
|
543
|
+
gracefulExit(state).catch(() => process.exit(1));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (key === "ctrl+d" && inputValue === "") {
|
|
547
|
+
gracefulExit(state).catch(() => process.exit(1));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (key === "ctrl+z") {
|
|
551
|
+
onSuspend?.();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (key === "escape" && state.running) {
|
|
555
|
+
if (state.abortController) state.abortController.abort();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
[
|
|
562
|
+
// ── Conversation log ──
|
|
563
|
+
renderConversationLog(state, cols),
|
|
564
|
+
|
|
565
|
+
// ── Animated divider (pulse when agent is working) ──
|
|
566
|
+
renderDivider(state, cols),
|
|
567
|
+
|
|
568
|
+
// ── Input area ──
|
|
569
|
+
renderInputArea(state.theme, inputController),
|
|
570
|
+
|
|
571
|
+
// ── Status bar (1 line) ──
|
|
572
|
+
renderStatusBar(state, cols),
|
|
573
|
+
],
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Start the terminal UI.
|
|
579
|
+
*
|
|
580
|
+
* Initializes cel-tui, sets up the viewport, and takes over the terminal.
|
|
581
|
+
* Does not return until the user exits.
|
|
582
|
+
*
|
|
583
|
+
* @param state - The initialized application state from {@link init}.
|
|
584
|
+
*/
|
|
585
|
+
export function startUI(state: AppState): void {
|
|
586
|
+
resetUiState();
|
|
587
|
+
stdinWasRaw = process.stdin.isRaw || false;
|
|
588
|
+
const terminal = new ProcessTerminal();
|
|
589
|
+
const inputController = createInputController(state);
|
|
590
|
+
cel.init(terminal);
|
|
591
|
+
|
|
592
|
+
cel.viewport(() => {
|
|
593
|
+
const cols = terminal.columns;
|
|
594
|
+
const base = renderBaseLayout(state, cols, inputController, () => {
|
|
595
|
+
suspendToBackground(() => {
|
|
596
|
+
resumeTerminalUi();
|
|
597
|
+
cel._getBuffer()?.clear();
|
|
598
|
+
if (state.running) {
|
|
599
|
+
startDividerAnimation();
|
|
600
|
+
}
|
|
601
|
+
cel.render();
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
const overlay = renderActiveOverlay(state);
|
|
605
|
+
|
|
606
|
+
if (overlay) {
|
|
607
|
+
return [base, overlay];
|
|
608
|
+
}
|
|
609
|
+
return base;
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
if (state.running) {
|
|
613
|
+
startDividerAnimation();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"WebSearch",
|
|
5
|
-
"WebFetch(domain:github.com)",
|
|
6
|
-
"WebFetch(domain:dev.to)",
|
|
7
|
-
"WebFetch(domain:mariozechner.at)",
|
|
8
|
-
"WebFetch(domain:lobste.rs)",
|
|
9
|
-
"WebFetch(domain:reading.sh)",
|
|
10
|
-
"WebFetch(domain:daveswift.com)",
|
|
11
|
-
"Bash(grep:*)",
|
|
12
|
-
"Bash(ls:*)",
|
|
13
|
-
"Bash(bun run:*)",
|
|
14
|
-
"Bash(git stash:*)",
|
|
15
|
-
"Bash(gh pr:*)",
|
|
16
|
-
"Bash(git status:*)",
|
|
17
|
-
"Bash(git add:*)",
|
|
18
|
-
"Bash(git commit:*)",
|
|
19
|
-
"Bash(sqlite3:*)",
|
|
20
|
-
"Bash(git branch:*)",
|
|
21
|
-
"Bash(git checkout:*)",
|
|
22
|
-
"Bash(npm info:*)",
|
|
23
|
-
"Bash(npm install:*)",
|
|
24
|
-
"Bash(find:*)",
|
|
25
|
-
"Bash(git log:*)",
|
|
26
|
-
"Bash(npm ls:*)",
|
|
27
|
-
"Bash(gh api:*)",
|
|
28
|
-
"Bash(bun:*)",
|
|
29
|
-
"Bash(npx tsc:*)",
|
|
30
|
-
"WebFetch(domain:docs.anthropic.com)",
|
|
31
|
-
"WebFetch(domain:sdk.vercel.ai)",
|
|
32
|
-
"WebFetch(domain:ai-sdk.dev)",
|
|
33
|
-
"WebFetch(domain:www.npmjs.com)",
|
|
34
|
-
"Bash(curl -sL https://registry.npmjs.org/opencode-anthropic-auth/-/opencode-anthropic-auth-0.0.13.tgz)",
|
|
35
|
-
"Bash(tar xz:*)",
|
|
36
|
-
"Bash(tmux new-session:*)",
|
|
37
|
-
"Bash(tmux capture-pane:*)",
|
|
38
|
-
"Bash(tmux list-sessions:*)",
|
|
39
|
-
"Bash(tmux kill-session:*)",
|
|
40
|
-
"Bash(python3:*)",
|
|
41
|
-
"Bash(echo \"EXIT: $?\")",
|
|
42
|
-
"Bash(xargs cat:*)",
|
|
43
|
-
"Bash(npm run:*)",
|
|
44
|
-
"Bash(wc -l /home/xonecas/src/mini-coder/src/cli/*.ts)",
|
|
45
|
-
"Bash(wc -l /home/xonecas/src/mini-coder/src/llm-api/*.ts)",
|
|
46
|
-
"Bash(cat:*)",
|
|
47
|
-
"Bash(wc -l /home/xonecas/src/mini-coder/src/**/*.ts)",
|
|
48
|
-
"Bash(bunx tsc:*)",
|
|
49
|
-
"Bash(find /home/xonecas/src/mini-coder -type f \\\\\\(-name *.1 -o -name mc.1 -o -name *man* \\\\\\))",
|
|
50
|
-
"WebFetch(domain:deepwiki.com)",
|
|
51
|
-
"WebFetch(domain:docs.openclaw.ai)"
|
|
52
|
-
]
|
|
53
|
-
}
|
|
54
|
-
}
|