s-app-scoder-agent 0.9.3 → 0.9.6
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 +615 -638
- package/dist/{guardrails.js → agent/guardrails.js} +1 -1
- package/dist/agent/interaction.js +70 -0
- package/dist/agent/mcp-bridge.js +136 -0
- package/dist/agent/prompt-context.js +83 -0
- package/dist/agent/run-loop.js +318 -0
- package/dist/agent/session-persistence.js +61 -0
- package/dist/agent/tool-executor.js +158 -0
- package/dist/agent/types.js +88 -0
- package/dist/agent/verification.js +99 -0
- package/dist/agent.js +239 -1002
- package/dist/cli/args.js +230 -0
- package/dist/cli/init.js +79 -0
- package/dist/cli/main.js +211 -0
- package/dist/cli/mcp-command.js +185 -0
- package/dist/cli/repl.js +77 -0
- package/dist/cli/run-once.js +26 -0
- package/dist/{tui/commands.js → commands/definitions.js} +6 -16
- package/dist/commands/registry.js +260 -0
- package/dist/{config.js → config/config.js} +1 -1
- package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
- package/dist/{prompt.js → context/prompt.js} +111 -9
- package/dist/{skills.js → context/skills.js} +41 -2
- package/dist/index.js +5 -798
- package/dist/io/console-reporter.js +24 -0
- package/dist/io/reporter.js +1 -0
- package/dist/mcp/connection.js +227 -0
- package/dist/mcp/env.js +8 -0
- package/dist/{mcp.js → mcp/manager.js} +3 -515
- package/dist/mcp/sse.js +28 -0
- package/dist/mcp/transport-http.js +140 -0
- package/dist/mcp/transport-stdio.js +98 -0
- package/dist/mcp/types.js +11 -0
- package/dist/media/image.js +149 -0
- package/dist/{server.js → server/server.js} +1 -1
- package/dist/{cost.js → session/cost.js} +1 -1
- package/dist/{session.js → session/session.js} +1 -1
- package/dist/{version.js → shared/version.js} +1 -1
- package/dist/tools/apply-patch.js +62 -0
- package/dist/tools/edit-file.js +29 -0
- package/dist/tools/file-edit-common.js +124 -0
- package/dist/tools/list-files.js +64 -0
- package/dist/tools/path-safety.js +75 -0
- package/dist/tools/read-file.js +64 -0
- package/dist/tools/registry.js +297 -0
- package/dist/tools/run-command.js +192 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/search.js +237 -0
- package/dist/tools/task-tool.js +48 -0
- package/dist/tools/types.js +1 -0
- package/dist/tools/write-file.js +34 -0
- package/dist/tui/app.js +59 -532
- package/dist/tui/ask-user-modal.js +2 -1
- package/dist/tui/at-complete.js +2 -93
- package/dist/tui/charset.js +41 -0
- package/dist/tui/chat-views.js +72 -16
- package/dist/tui/chrome.js +11 -5
- package/dist/tui/hooks/use-command-host.js +26 -0
- package/dist/tui/hooks/use-event-queue.js +69 -0
- package/dist/tui/hooks/use-keybinds.js +87 -0
- package/dist/tui/hooks/use-scroll.js +38 -0
- package/dist/tui/hooks/use-session-resume.js +47 -0
- package/dist/tui/hooks/use-submit.js +106 -0
- package/dist/tui/image.js +2 -149
- package/dist/tui/input.js +1 -1
- package/dist/tui/markdown/ast.js +5 -0
- package/dist/tui/markdown/diagram.js +239 -0
- package/dist/tui/markdown/inline-render.js +24 -0
- package/dist/tui/markdown/inline.js +93 -0
- package/dist/tui/markdown/parse.js +139 -0
- package/dist/tui/markdown/render.js +146 -0
- package/dist/tui/markdown/table.js +106 -0
- package/dist/tui/markdown.js +32 -379
- package/dist/tui/permission-modal.js +2 -1
- package/dist/tui/selectors.js +157 -0
- package/dist/tui/session-picker.js +4 -3
- package/dist/tui/sidebar.js +130 -15
- package/dist/tui/state.js +47 -118
- package/dist/tui/text-width.js +139 -0
- package/dist/tui/theme.js +7 -2
- package/dist/{build.js → workspace/build.js} +1 -1
- package/dist/workspace/file-index.js +93 -0
- package/package.json +5 -1
- package/dist/tools.js +0 -1223
- package/dist/tui/command-registry.js +0 -477
- package/dist/tui/review.js +0 -64
- package/dist/tui/transcript.js +0 -145
- /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
- /package/dist/{agents.js → context/agents.js} +0 -0
- /package/dist/{compaction.js → context/compaction.js} +0 -0
- /package/dist/{context.js → context/context.js} +0 -0
- /package/dist/{memory.js → context/memory.js} +0 -0
- /package/dist/{repo-map.js → context/repo-map.js} +0 -0
- /package/dist/{ui.js → io/terminal.js} +0 -0
- /package/dist/{llm.js → llm/llm.js} +0 -0
- /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
- /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
- /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
- /package/dist/{retry.js → llm/retry.js} +0 -0
- /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
- /package/dist/{permissions.js → security/permissions.js} +0 -0
- /package/dist/{sandbox.js → security/sandbox.js} +0 -0
- /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
- /package/dist/{logger.js → session/logger.js} +0 -0
- /package/dist/{steering.js → session/steering.js} +0 -0
- /package/dist/{constants.js → shared/constants.js} +0 -0
- /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
- /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
- /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
- /package/dist/{diff.js → workspace/diff.js} +0 -0
- /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
- /package/dist/{watch.js → workspace/watch.js} +0 -0
package/dist/tui/app.js
CHANGED
|
@@ -4,7 +4,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
*
|
|
5
5
|
* Identity (deliberately distinct from chat-first agent UIs): the build is
|
|
6
6
|
* the mission, so the screen is organized around it —
|
|
7
|
-
* · Build Rail (top): brand,
|
|
7
|
+
* · Build Rail (top): brand, permission badge, build lamp
|
|
8
|
+
* · Sidebar (right panel): session · MCP · metrics · phases · plan · build
|
|
8
9
|
* · Flight-recorder log: each tool entry is stamped `07 ▸ name(args)`
|
|
9
10
|
* · `✎` diff cards rendered inline for every file edit
|
|
10
11
|
* · Assistant messages render as markdown (code fences, headers, lists)
|
|
@@ -12,26 +13,48 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
12
13
|
* Driven entirely by AgentEvents (the same stream the HTTP/SSE server uses).
|
|
13
14
|
*/
|
|
14
15
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
-
import { Box, Text, useApp,
|
|
16
|
+
import { Box, Text, useApp, useWindowSize } from "ink";
|
|
16
17
|
import { Agent } from "../agent.js";
|
|
17
|
-
import { setQuiet } from "../
|
|
18
|
-
import { discoverSkills } from "../skills.js";
|
|
19
|
-
import { discoverAgents } from "../agents.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { parseSlashCommand, SLASH_COMMANDS, formatSlashHelp } from "./commands.js";
|
|
23
|
-
import { dispatchCommand } from "./command-registry.js";
|
|
18
|
+
import { setQuiet } from "../io/terminal.js";
|
|
19
|
+
import { discoverSkills } from "../context/skills.js";
|
|
20
|
+
import { discoverAgents } from "../context/agents.js";
|
|
21
|
+
import { initialState } from "./state.js";
|
|
22
|
+
import { SLASH_COMMANDS, formatSlashHelp } from "../commands/definitions.js";
|
|
24
23
|
import { ChatItemView } from "./chat-views.js"; // T2
|
|
25
|
-
import { BuildRail, StatusBar, SIDEBAR_MIN_COLS } from "./chrome.js"; // T2
|
|
24
|
+
import { BuildRail, StatusBar, SIDEBAR_MIN_COLS, SIDEBAR_WIDTH } from "./chrome.js"; // T2
|
|
26
25
|
import { Input } from "./input.js"; // T2
|
|
27
26
|
import { SessionPicker } from "./session-picker.js"; // session picker modal
|
|
28
27
|
import { fileIndex } from "./at-complete.js";
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import { reviewFromResponse, reviewAnswerToCall } from "./review.js"; // T2
|
|
28
|
+
import { getTheme, setActiveTheme, theme } from "./theme.js";
|
|
29
|
+
import { boxBorderStyle, isAsciiMode } from "./charset.js";
|
|
32
30
|
import { PermissionModal } from "./permission-modal.js"; // T2
|
|
33
31
|
import { Sidebar } from "./sidebar.js"; // sidebar opencode
|
|
34
|
-
import { getVersion } from "../version.js";
|
|
32
|
+
import { getVersion } from "../shared/version.js";
|
|
33
|
+
import { useScrollMetrics } from "./hooks/use-scroll.js";
|
|
34
|
+
/**
|
|
35
|
+
* Logo lớn cho màn hình welcome (block letters, ~49 cols) — chỉ vẽ khi pane
|
|
36
|
+
* chat đủ rộng, ngược lại fallback dòng "⚑ SCoder". ASCII mode: biến thể '#'.
|
|
37
|
+
*/
|
|
38
|
+
const BANNER = [
|
|
39
|
+
"███████╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ ",
|
|
40
|
+
"██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗",
|
|
41
|
+
"███████╗██║ ██║ ██║██║ ██║█████╗ ██████╔╝",
|
|
42
|
+
"╚════██║██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗",
|
|
43
|
+
"███████║╚██████╗╚██████╔╝██████╔╝███████╗██║ ██║",
|
|
44
|
+
"╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝",
|
|
45
|
+
];
|
|
46
|
+
const BANNER_ASCII = [
|
|
47
|
+
"##### #### #### ##### ###### ##### ",
|
|
48
|
+
"# # # # # # # # #",
|
|
49
|
+
"##### # # # # # #### ##### ",
|
|
50
|
+
" # # # # # # # # # ",
|
|
51
|
+
"##### #### #### ##### ###### # #",
|
|
52
|
+
];
|
|
53
|
+
import { useEventQueue } from "./hooks/use-event-queue.js";
|
|
54
|
+
import { useSessionResume } from "./hooks/use-session-resume.js";
|
|
55
|
+
import { useCommandHost } from "./hooks/use-command-host.js";
|
|
56
|
+
import { useTuiKeybinds } from "./hooks/use-keybinds.js";
|
|
57
|
+
import { useSubmit } from "./hooks/use-submit.js";
|
|
35
58
|
export function TuiApp({ config, initialTask }) {
|
|
36
59
|
// v2: the effective config can be hot-patched by slash commands (/agent,
|
|
37
60
|
// /permission) — the prop is the initial value, cfg is the live one.
|
|
@@ -72,52 +95,13 @@ export function TuiApp({ config, initialTask }) {
|
|
|
72
95
|
agentRef.current?.dispose();
|
|
73
96
|
agentRef.current = null;
|
|
74
97
|
}, []);
|
|
75
|
-
// ── Prompt-gate review flow (/review) ──────────────────────────────────────
|
|
76
|
-
// State machine relaying the universal-prompt-gate-mcp text-fallback flow:
|
|
77
|
-
// call → pending_level_selection → user picks level → pending_user_selection
|
|
78
|
-
// → user picks original/rewritten/update/cancel → approved/cancelled.
|
|
79
|
-
// `argsBase` holds the immutable prompt/context/language; `rewrittenPrompt`
|
|
80
|
-
// is echoed back verbatim from the server's pending result (never from the
|
|
81
|
-
// user or LLM) so only exactly what the user saw can be approved.
|
|
82
|
-
const reviewRef = useRef(null);
|
|
83
|
-
const reviewBusyRef = useRef(false);
|
|
84
98
|
// ── Scrollable chat viewport (Ink 8 alt-screen) ──
|
|
85
99
|
// The viewport clips overflow; the inner content wrapper keeps its natural
|
|
86
100
|
// height so it can be measured and offset upward for scrolling.
|
|
87
|
-
const viewportRef =
|
|
88
|
-
const contentRef = useRef(null);
|
|
89
|
-
const viewport = useBoxMetrics(viewportRef);
|
|
90
|
-
const content = useBoxMetrics(contentRef);
|
|
91
|
-
const viewportRowsRef = useRef(null);
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
viewportRowsRef.current = viewport.clientHeight || null;
|
|
94
|
-
}, [viewport.clientHeight]);
|
|
101
|
+
const { viewportRef, contentRef, viewport, content, viewportRowsRef } = useScrollMetrics(setScrollOffset);
|
|
95
102
|
const maxScroll = Math.max(0, content.height - viewport.clientHeight);
|
|
96
103
|
// scrollOffset counts rows UP from the bottom; 0 = follow the newest item.
|
|
97
104
|
const scrollTop = Math.max(0, maxScroll - scrollOffset);
|
|
98
|
-
// T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
|
|
99
|
-
// drops unrepresentable CSI sequences, so wheel events must be read here.
|
|
100
|
-
// Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
|
|
101
|
-
const { stdin, setRawMode, isRawModeSupported } = useStdin();
|
|
102
|
-
const wheelScroll = useCallback((dir) => {
|
|
103
|
-
setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
|
|
104
|
-
}, []);
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
if (process.stdout.isTTY !== true || !isRawModeSupported)
|
|
107
|
-
return;
|
|
108
|
-
process.stdout.write(MOUSE_ENABLE);
|
|
109
|
-
setRawMode(true);
|
|
110
|
-
const onData = (chunk) => {
|
|
111
|
-
const dir = parseWheel(String(chunk));
|
|
112
|
-
if (dir)
|
|
113
|
-
wheelScroll(dir);
|
|
114
|
-
};
|
|
115
|
-
stdin.on("data", onData);
|
|
116
|
-
return () => {
|
|
117
|
-
stdin.off("data", onData);
|
|
118
|
-
process.stdout.write(MOUSE_DISABLE);
|
|
119
|
-
};
|
|
120
|
-
}, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
|
|
121
105
|
const skills = useMemo(() => discoverSkills(cfg.workdir), [cfg.workdir]);
|
|
122
106
|
const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
|
|
123
107
|
// T-02: completion sources (agents + files) computed once per mount.
|
|
@@ -127,406 +111,21 @@ export function TuiApp({ config, initialTask }) {
|
|
|
127
111
|
files: fileIndex(cfg.workdir),
|
|
128
112
|
}), [cfg.workdir, skillNames]);
|
|
129
113
|
// Mirror of state for the submit path (isFirst, /cost, /model) and the
|
|
130
|
-
// abort handler — kept in sync synchronously by update()
|
|
114
|
+
// abort handler — kept in sync synchronously by update(), so it is
|
|
131
115
|
// exact even between renders (event bursts land between them).
|
|
132
|
-
const stateRef =
|
|
133
|
-
/**
|
|
134
|
-
* Central state updater — the single source of truth is stateRef, updated
|
|
135
|
-
* synchronously so event handlers never race a render cycle.
|
|
136
|
-
*/
|
|
137
|
-
const update = useCallback((fn) => {
|
|
138
|
-
stateRef.current = fn(stateRef.current);
|
|
139
|
-
setState(stateRef.current);
|
|
140
|
-
}, []);
|
|
141
|
-
/**
|
|
142
|
-
* Event batching: streaming (`llm_delta`) and live tool output (`tool_output`)
|
|
143
|
-
* can emit hundreds of events per second; each one previously triggered a
|
|
144
|
-
* full-tree re-render (the flicker). Buffer incoming events and flush them
|
|
145
|
-
* through the reducer at most every FLUSH_MS (~20 fps, well above what the
|
|
146
|
-
* eye needs for streaming text).
|
|
147
|
-
*/
|
|
148
|
-
const FLUSH_MS = 50;
|
|
149
|
-
const eventQueue = useRef([]);
|
|
150
|
-
const flushTimer = useRef(null);
|
|
151
|
-
const flushEvents = useCallback(() => {
|
|
152
|
-
flushTimer.current = null;
|
|
153
|
-
const events = eventQueue.current;
|
|
154
|
-
if (events.length === 0)
|
|
155
|
-
return;
|
|
156
|
-
eventQueue.current = [];
|
|
157
|
-
update((s) => {
|
|
158
|
-
let next = s;
|
|
159
|
-
for (const ev of events)
|
|
160
|
-
next = applyEvent(next, ev);
|
|
161
|
-
return { ...next, items: [...next.items] };
|
|
162
|
-
});
|
|
163
|
-
}, [update]);
|
|
164
|
-
// Realtime tick: re-render at least once per second while a run is active so
|
|
165
|
-
// sidebar metrics (elapsed time, ctx pressure, tok/s during/after a stream)
|
|
166
|
-
// update live instead of only on the next event/command boundary.
|
|
167
|
-
useEffect(() => {
|
|
168
|
-
const id = setInterval(() => {
|
|
169
|
-
if (!runningRef.current)
|
|
170
|
-
return;
|
|
171
|
-
update((s) => ({ ...s }));
|
|
172
|
-
}, 1000);
|
|
173
|
-
return () => clearInterval(id);
|
|
174
|
-
}, [update]);
|
|
175
|
-
const queueEvent = useCallback((ev) => {
|
|
176
|
-
// T-03: permission modal must appear instantly (not batched) — the run
|
|
177
|
-
// blocks on the answer.
|
|
178
|
-
if (ev.type === "permission_request") {
|
|
179
|
-
setPermPrompt(ev);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
eventQueue.current.push(ev);
|
|
183
|
-
if (!flushTimer.current)
|
|
184
|
-
flushTimer.current = setTimeout(flushEvents, FLUSH_MS);
|
|
185
|
-
}, [flushEvents]);
|
|
186
|
-
// Flush on unmount so a trailing burst is never dropped.
|
|
187
|
-
useEffect(() => () => {
|
|
188
|
-
if (flushTimer.current)
|
|
189
|
-
clearTimeout(flushTimer.current);
|
|
190
|
-
flushEvents();
|
|
191
|
-
}, [flushEvents]);
|
|
116
|
+
const { stateRef, update, queueEvent, eventQueue } = useEventQueue(state, setState, setPermPrompt, runningRef);
|
|
192
117
|
const pushNotice = useCallback((text, level = "info") => update((s) => ({ ...s, items: [...s.items, { kind: "notice", text, level }] })), [update]);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
// Restore full TUI state from the session (items + step count + model info).
|
|
206
|
-
update(() => ({
|
|
207
|
-
...initialState(),
|
|
208
|
-
items: s.messages
|
|
209
|
-
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
210
|
-
.map((m) => m.role === "user"
|
|
211
|
-
? { kind: "user", text: typeof m.content === "string" ? m.content : "" }
|
|
212
|
-
: { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false }),
|
|
213
|
-
step: s.steps ?? 0,
|
|
214
|
-
maxSteps: s.maxSteps ?? cfg.maxSteps,
|
|
215
|
-
model: s.model || cfg.model,
|
|
216
|
-
provider: s.provider || cfg.provider,
|
|
217
|
-
status: "idle",
|
|
218
|
-
summary: null,
|
|
219
|
-
}));
|
|
220
|
-
runningRef.current = true;
|
|
221
|
-
setScrollOffset(0);
|
|
222
|
-
const agent = createAgent();
|
|
223
|
-
try {
|
|
224
|
-
await agent.resume(s);
|
|
225
|
-
runningRef.current = false;
|
|
226
|
-
}
|
|
227
|
-
catch (err) {
|
|
228
|
-
runningRef.current = false;
|
|
229
|
-
pushNotice(`resume failed: ${err.message}`, "error");
|
|
230
|
-
}
|
|
231
|
-
}, [cfg, createAgent, pushNotice, update]);
|
|
232
|
-
/** T1: CommandHost cho registry dùng chung với REPL. */
|
|
233
|
-
const tuiHost = useCallback(() => ({
|
|
234
|
-
agent: () => agentRef.current,
|
|
235
|
-
config: cfg,
|
|
236
|
-
updateConfig: (patch) => setCfg((c) => ({ ...c, ...patch })),
|
|
237
|
-
isRunning: () => runningRef.current,
|
|
238
|
-
replaceAgent: (next) => {
|
|
239
|
-
agentRef.current?.abort();
|
|
240
|
-
agentRef.current?.dispose();
|
|
241
|
-
agentRef.current = next;
|
|
242
|
-
if (!next) {
|
|
243
|
-
runningRef.current = false;
|
|
244
|
-
setScrollOffset(0);
|
|
245
|
-
eventQueue.current = [];
|
|
246
|
-
update(() => initialState());
|
|
247
|
-
}
|
|
248
|
-
setPermMode(cfg.permissionMode);
|
|
249
|
-
},
|
|
250
|
-
resumeAndRun: async (s) => {
|
|
251
|
-
const agent = createAgent();
|
|
252
|
-
// Replace the pane with the restored transcript, then continue.
|
|
253
|
-
update(() => ({
|
|
254
|
-
...initialState(),
|
|
255
|
-
items: s.messages
|
|
256
|
-
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
257
|
-
.map((m) => (m.role === "user" ? { kind: "user", text: typeof m.content === "string" ? m.content : "" } : { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false })),
|
|
258
|
-
}));
|
|
259
|
-
runningRef.current = true;
|
|
260
|
-
setScrollOffset(0);
|
|
261
|
-
try {
|
|
262
|
-
await agent.resume(s);
|
|
263
|
-
runningRef.current = false;
|
|
264
|
-
}
|
|
265
|
-
catch (err) {
|
|
266
|
-
runningRef.current = false;
|
|
267
|
-
pushNotice(`resume failed: ${err.message}`, "error");
|
|
268
|
-
}
|
|
269
|
-
return { success: true, steps: 0, buildCmd: cfg.buildCmd, summary: "resumed" };
|
|
270
|
-
},
|
|
271
|
-
/** [Phase 7] Transcript items — used by /export and /search. */
|
|
272
|
-
transcriptItems: () => stateRef.current.items,
|
|
273
|
-
/** [Phase 7] Scroll to a transcript item by index (best-effort row estimation). */
|
|
274
|
-
selectTranscriptItem: (index) => {
|
|
275
|
-
const items = stateRef.current.items;
|
|
276
|
-
if (index < 0 || index >= items.length)
|
|
277
|
-
return;
|
|
278
|
-
// target: show item at index at the TOP of the viewport.
|
|
279
|
-
// Ink contentOffsetY shifts content upward by that many rows.
|
|
280
|
-
let above = 0;
|
|
281
|
-
for (let i = 0; i < index; i++)
|
|
282
|
-
above += estimateItemRows(items[i]);
|
|
283
|
-
const maxScroll = Math.max(0, content.height - viewport.height);
|
|
284
|
-
setScrollOffset(Math.max(0, maxScroll - above));
|
|
285
|
-
},
|
|
286
|
-
}), [cfg, update, pushNotice, createAgent, viewport, content]);
|
|
287
|
-
/** One round of the /review state machine: call the gate, render the result. */
|
|
288
|
-
const reviewStep = useCallback(async (tool, args) => {
|
|
289
|
-
const agent = agentRef.current ?? createAgent();
|
|
290
|
-
reviewBusyRef.current = true;
|
|
291
|
-
try {
|
|
292
|
-
const r = await agent.callMcpTool(tool, args);
|
|
293
|
-
const out = reviewFromResponse(reviewRef.current, tool, args, r);
|
|
294
|
-
reviewRef.current = out.state;
|
|
295
|
-
if (out.notice)
|
|
296
|
-
pushNotice(out.notice.text, out.notice.level);
|
|
297
|
-
}
|
|
298
|
-
catch (err) {
|
|
299
|
-
reviewRef.current = null;
|
|
300
|
-
pushNotice(`prompt gate failed: ${err.message}`, "error");
|
|
301
|
-
}
|
|
302
|
-
finally {
|
|
303
|
-
reviewBusyRef.current = false;
|
|
304
|
-
}
|
|
305
|
-
}, [createAgent, pushNotice]);
|
|
306
|
-
/** Handle a user reply while /review is waiting (numbers 1-4 / feedback text). */
|
|
307
|
-
const reviewAnswer = useCallback((text) => {
|
|
308
|
-
const rv = reviewRef.current;
|
|
309
|
-
if (!rv || reviewBusyRef.current)
|
|
310
|
-
return false;
|
|
311
|
-
const out = reviewAnswerToCall(rv, text);
|
|
312
|
-
if (out.notice)
|
|
313
|
-
pushNotice(out.notice.text, out.notice.level);
|
|
314
|
-
if (out.nextCall)
|
|
315
|
-
void reviewStep(out.nextCall.tool, out.nextCall.args);
|
|
316
|
-
return true;
|
|
317
|
-
}, [reviewStep, pushNotice]);
|
|
318
|
-
// Close any lingering streaming messages when the user sends a new query —
|
|
319
|
-
// prevents green/dim text from the previous response appearing stale.
|
|
320
|
-
const closeStreamingItems = useCallback(() => {
|
|
321
|
-
update((s) => {
|
|
322
|
-
let changed = false;
|
|
323
|
-
for (const it of s.items) {
|
|
324
|
-
if (it.kind === "assistant" && it.streaming) {
|
|
325
|
-
it.streaming = false;
|
|
326
|
-
changed = true;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
return changed ? { ...s, items: [...s.items] } : s;
|
|
330
|
-
});
|
|
331
|
-
}, [update]);
|
|
332
|
-
/**
|
|
333
|
-
* Shared entry point for every task submission:
|
|
334
|
-
* 1. Close any still-streaming assistant message from the previous response.
|
|
335
|
-
* 2. Record the query in history.
|
|
336
|
-
* 3. Clear the input field.
|
|
337
|
-
* Then the caller handles steer vs. new-run separately.
|
|
338
|
-
*/
|
|
339
|
-
const preSubmit = useCallback((task) => {
|
|
340
|
-
closeStreamingItems();
|
|
341
|
-
historyRef.current = [...historyRef.current, task].slice(-100);
|
|
342
|
-
writeInput("");
|
|
343
|
-
}, [closeStreamingItems, writeInput]);
|
|
344
|
-
const submit = useCallback(async (text) => {
|
|
345
|
-
let task = text.trim();
|
|
346
|
-
if (!task)
|
|
347
|
-
return;
|
|
348
|
-
// Prompt-gate review in progress — numeric replies drive the flow.
|
|
349
|
-
if (reviewRef.current) {
|
|
350
|
-
writeInput("");
|
|
351
|
-
historyRef.current = [...historyRef.current, task].slice(-100);
|
|
352
|
-
reviewAnswer(task);
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
// "exit"/"quit" always works — aborts a running agent first.
|
|
356
|
-
if (task === "exit" || task === "quit") {
|
|
357
|
-
agentRef.current?.abort();
|
|
358
|
-
exit();
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
// Slash commands — TUI-specific first (/review /diff /theme /undo),
|
|
362
|
-
// then the shared registry (T1) for the rest.
|
|
363
|
-
const slash = parseSlashCommand(task);
|
|
364
|
-
if (slash) {
|
|
365
|
-
writeInput("");
|
|
366
|
-
if (slash.cmd === "/review") {
|
|
367
|
-
if (!slash.arg) {
|
|
368
|
-
pushNotice("usage: /review <prompt> — rewrites it, you pick original/rewritten/update/cancel", "warn");
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
// v2: no hardcoded server/language — any MCP server exposing a
|
|
372
|
-
// review_and_select_prompt tool wins; language is auto-detected by
|
|
373
|
-
// the gate from the prompt itself.
|
|
374
|
-
const servers = Object.keys(cfg.mcpServers ?? {});
|
|
375
|
-
const gateServer = servers.find((s) => s === "prompt-gate") ?? servers.find((s) => /gate/i.test(s));
|
|
376
|
-
if (!gateServer) {
|
|
377
|
-
pushNotice("no MCP prompt-gate server configured — add one exposing review_and_select_prompt to ~/.scoder/config.json", "error");
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
// Context grounded in this session: last user item + current workdir.
|
|
381
|
-
const lastUser = [...stateRef.current.items].reverse().find((i) => i.kind === "user");
|
|
382
|
-
const context = `User is in scoder TUI at ${cfg.workdir}${lastUser ? `; earlier they asked: "${lastUser.text.slice(0, 120)}"` : ""}.`;
|
|
383
|
-
void reviewStep(`mcp__${gateServer}__review_and_select_prompt`, {
|
|
384
|
-
prompt: slash.arg,
|
|
385
|
-
context,
|
|
386
|
-
});
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
if (slash.cmd === "/diff") {
|
|
390
|
-
// T-06: /diff turn | session | branch (default: turn). TUI-specific —
|
|
391
|
-
// turn scope reads the reducer's diff items.
|
|
392
|
-
const scope = slash.arg.trim() || "turn";
|
|
393
|
-
const diffs = stateRef.current.items.filter((i) => i.kind === "diff");
|
|
394
|
-
if (scope === "turn") {
|
|
395
|
-
// Diffs since the last user message (current turn).
|
|
396
|
-
let count = 0;
|
|
397
|
-
const turnDiffs = [];
|
|
398
|
-
for (let i = stateRef.current.items.length - 1; i >= 0; i--) {
|
|
399
|
-
const it = stateRef.current.items[i];
|
|
400
|
-
if (it.kind === "user" && count > 0)
|
|
401
|
-
break;
|
|
402
|
-
if (it.kind === "diff") {
|
|
403
|
-
count++;
|
|
404
|
-
turnDiffs.unshift(`✎ ${it.path} (+${it.added}/-${it.removed})\n${it.text}`);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
pushNotice(turnDiffs.length ? turnDiffs.join("\n\n") : "no file edits in this turn yet");
|
|
408
|
-
}
|
|
409
|
-
else if (scope === "session") {
|
|
410
|
-
const total = diffs.reduce((a, d) => ({ add: a.add + d.added, rem: a.rem + d.removed }), { add: 0, rem: 0 });
|
|
411
|
-
pushNotice(diffs.length === 0
|
|
412
|
-
? "no file edits this session"
|
|
413
|
-
: diffs.map((d) => `✎ ${d.path} (+${d.added}/-${d.removed})`).join("\n") + `\n${diffs.length} files · +${total.add}/-${total.rem}`);
|
|
414
|
-
}
|
|
415
|
-
else if (scope === "branch") {
|
|
416
|
-
const agent = agentRef.current;
|
|
417
|
-
const snaps = agent?.snapshots.list() ?? [];
|
|
418
|
-
if (!agent || snaps.length === 0) {
|
|
419
|
-
pushNotice("no snapshot checkpoints — /diff branch needs at least one captured edit batch");
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
const d = agent.snapshots.diff(snaps[0].tree, null);
|
|
423
|
-
pushNotice(d ? d : "worktree matches the first checkpoint (no net change)");
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
else {
|
|
427
|
-
pushNotice("usage: /diff [turn|session|branch]", "warn");
|
|
428
|
-
}
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (slash.cmd === "/theme") {
|
|
432
|
-
// T-09: palette hot-swap.
|
|
433
|
-
if (!slash.arg) {
|
|
434
|
-
pushNotice(`themes: ${Object.keys(THEMES).join(", ")} — /theme <name>; project default qua .scoder/theme.json`);
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
pushNotice(useNamedTheme(slash.arg.trim()), "info");
|
|
438
|
-
bumpFrame();
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
if (slash.cmd === "/undo") {
|
|
442
|
-
// T-07: /undo [edit|turn|files]
|
|
443
|
-
// (bare)|files — revert files to the previous checkpoint.
|
|
444
|
-
// edit — files + trim conversation back one turn, then the user
|
|
445
|
-
// can edit and re-send (fork/edit-resend, opencode UX).
|
|
446
|
-
const agent = agentRef.current;
|
|
447
|
-
const mode = slash.arg.trim() || "files";
|
|
448
|
-
const cps = agent?.snapshots.list() ?? [];
|
|
449
|
-
if (!agent || cps.length < 2) {
|
|
450
|
-
pushNotice("undo: no snapshot checkpoint to revert to", "warn");
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
if (mode !== "files" && mode !== "edit") {
|
|
454
|
-
pushNotice("usage: /undo [files|edit]", "warn");
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
// Revert to the second-newest checkpoint (newest = current state).
|
|
458
|
-
const target = cps[cps.length - 2];
|
|
459
|
-
const ok = agent.snapshots.restore(target.tree);
|
|
460
|
-
if (!ok) {
|
|
461
|
-
pushNotice("undo: restore failed", "warn");
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
if (mode === "edit") {
|
|
465
|
-
// Fork: trim the transcript UI back to the last user message and
|
|
466
|
-
// put it in the input for editing + re-send.
|
|
467
|
-
const items = stateRef.current.items;
|
|
468
|
-
let lastUserIdx = -1;
|
|
469
|
-
for (let i = items.length - 1; i >= 0; i--)
|
|
470
|
-
if (items[i].kind === "user") {
|
|
471
|
-
lastUserIdx = i;
|
|
472
|
-
break;
|
|
473
|
-
}
|
|
474
|
-
const resend = lastUserIdx >= 0 && items[lastUserIdx].kind === "user" ? items[lastUserIdx].text : "";
|
|
475
|
-
update((s) => ({ ...s, items: lastUserIdx >= 0 ? items.slice(0, lastUserIdx) : s.items, status: "idle", summary: null }));
|
|
476
|
-
if (resend)
|
|
477
|
-
writeInput(resend);
|
|
478
|
-
agent.trimLastTurn();
|
|
479
|
-
pushNotice(`undo edit: files → ${target.id}; last task back in the input — edit + re-send`, "info");
|
|
480
|
-
}
|
|
481
|
-
else {
|
|
482
|
-
pushNotice(`undo: reverted to ${target.id} (${target.label})`);
|
|
483
|
-
}
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
// T1: phần còn lại qua registry dùng chung với REPL.
|
|
487
|
-
const { handled, outcome } = await dispatchCommand(task, tuiHost(), pushNotice);
|
|
488
|
-
if (handled && outcome?.quit) {
|
|
489
|
-
agentRef.current?.abort();
|
|
490
|
-
exit();
|
|
491
|
-
}
|
|
492
|
-
if (handled && outcome?.task) {
|
|
493
|
-
task = outcome.task;
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
if (runningRef.current) {
|
|
500
|
-
// Steer: user input while the agent runs — deliver at next step
|
|
501
|
-
// boundary (H-08, "steer by default" — pattern from opencode).
|
|
502
|
-
preSubmit(task);
|
|
503
|
-
agentRef.current?.steer(task);
|
|
504
|
-
update((s) => ({ ...s, items: [...s.items, { kind: "user", text: `${task} (steered)` }] }));
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
preSubmit(task);
|
|
508
|
-
runningRef.current = true;
|
|
509
|
-
setScrollOffset(0);
|
|
510
|
-
// isFirst must be computed BEFORE pushing the user item below.
|
|
511
|
-
const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
|
|
512
|
-
// runTurn() doesn't emit run_start, so the TUI shows the user's message
|
|
513
|
-
// itself; applyEvent's run_start dedupe keeps turn 1 from doubling.
|
|
514
|
-
update((s) => ({ ...s, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
|
|
515
|
-
const agent = createAgent();
|
|
516
|
-
try {
|
|
517
|
-
// First submission runs the full closed loop; later ones continue the session.
|
|
518
|
-
const result = isFirst ? await agent.run(task) : await agent.runTurn(task);
|
|
519
|
-
void result;
|
|
520
|
-
runningRef.current = false;
|
|
521
|
-
}
|
|
522
|
-
catch (err) {
|
|
523
|
-
runningRef.current = false;
|
|
524
|
-
// Surface the failure through the reducer (not just a notice) so the
|
|
525
|
-
// status leaves "running" and the error is visible as the final line.
|
|
526
|
-
update((s) => applyEvent({ ...s, items: [...s.items] }, { type: "finish", success: false, steps: s.step, summary: `Run failed: ${err.message}` }));
|
|
527
|
-
update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: err.message, level: "error" }] }));
|
|
528
|
-
}
|
|
529
|
-
}, [cfg, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, reviewAnswer, reviewStep, tuiHost, preSubmit]);
|
|
118
|
+
const handleSessionSelect = useSessionResume({
|
|
119
|
+
cfg, createAgent, pushNotice, update, runningRef, setScrollOffset, setSessionPickerOpen,
|
|
120
|
+
});
|
|
121
|
+
const tuiHost = useCommandHost({
|
|
122
|
+
cfg, setCfg, update, agentRef, runningRef, eventQueue, setScrollOffset, setPermMode,
|
|
123
|
+
});
|
|
124
|
+
const { submit } = useSubmit({
|
|
125
|
+
cfg, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode,
|
|
126
|
+
tuiHost, agentRef, runningRef, stateRef,
|
|
127
|
+
historyRef, setScrollOffset, createAgent, bumpFrame,
|
|
128
|
+
});
|
|
530
129
|
/** Create (or return) the session agent — permission mode always current. */
|
|
531
130
|
function createAgent() {
|
|
532
131
|
if (agentRef.current)
|
|
@@ -539,85 +138,10 @@ export function TuiApp({ config, initialTask }) {
|
|
|
539
138
|
agentRef.current = agent;
|
|
540
139
|
return agent;
|
|
541
140
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
scrollOffsetRef.current = scrollOffset;
|
|
547
|
-
}, [scrollOffset]);
|
|
548
|
-
// T-03: permission modal keybinds — intercepts y/a/n/q while open.
|
|
549
|
-
useInput((input, key) => {
|
|
550
|
-
const p = permPromptRef.current;
|
|
551
|
-
if (!p)
|
|
552
|
-
return;
|
|
553
|
-
const answer = key.return ? "yes" : input.toLowerCase() === "y" ? "yes" : input.toLowerCase() === "a" ? "always" : input.toLowerCase() === "n" ? "no" : input.toLowerCase() === "q" ? "quit" : key.escape ? "no" : null;
|
|
554
|
-
if (!answer)
|
|
555
|
-
return;
|
|
556
|
-
setPermPrompt(null);
|
|
557
|
-
agentRef.current?.resolvePermission(p.id, answer);
|
|
558
|
-
});
|
|
559
|
-
// T-01/T-02: scroll + abort keybinds.
|
|
560
|
-
useInput((_input, key) => {
|
|
561
|
-
if (permPromptRef.current)
|
|
562
|
-
return; // modal owns the keyboard
|
|
563
|
-
// ── Ctrl+S: open session picker (when not running, not already open) ──
|
|
564
|
-
if (key.ctrl && _input === "s") {
|
|
565
|
-
if (!runningRef.current && !sessionPickerOpen) {
|
|
566
|
-
setSessionPickerOpen(true);
|
|
567
|
-
}
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
// ── Ctrl+P: command palette ──
|
|
571
|
-
if (key.ctrl && _input === "p") {
|
|
572
|
-
setPaletteOpen((o) => !o);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
// ── ?: help overlay (when input empty) ──
|
|
576
|
-
if (_input === "?" && !inputRef.current.trim()) {
|
|
577
|
-
setHelpOpen((o) => !o);
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
// T-10: ctrl+t toggles the thinking stream visibility.
|
|
581
|
-
if (key.ctrl && _input === "t") {
|
|
582
|
-
update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
if (key.pageUp || key.pageDown || key.home || key.end) {
|
|
586
|
-
const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
|
|
587
|
-
if (key.pageUp)
|
|
588
|
-
setScrollOffset((o) => o + page);
|
|
589
|
-
if (key.pageDown)
|
|
590
|
-
setScrollOffset((o) => Math.max(0, o - page));
|
|
591
|
-
if (key.home)
|
|
592
|
-
setScrollOffset(999_999); // top (clamped to maxScroll in render)
|
|
593
|
-
if (key.end)
|
|
594
|
-
setScrollOffset(0); // bottom = follow
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
if (key.escape) {
|
|
598
|
-
if (scrollOffsetRef.current > 0) {
|
|
599
|
-
setScrollOffset(0);
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
if (runningRef.current) {
|
|
603
|
-
update((s) => ({ ...markAborted(s), items: [...s.items] }));
|
|
604
|
-
agentRef.current?.abort();
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
});
|
|
608
|
-
// Ctrl+C quits (press again within 2s to confirm while running).
|
|
609
|
-
const ctrlCArmed = useRef(null);
|
|
610
|
-
useInput((_input, key) => {
|
|
611
|
-
if (!key.ctrl || _input !== "c")
|
|
612
|
-
return;
|
|
613
|
-
if (runningRef.current && ctrlCArmed.current === null) {
|
|
614
|
-
ctrlCArmed.current = Date.now();
|
|
615
|
-
update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
|
|
616
|
-
setTimeout(() => (ctrlCArmed.current = null), 2000);
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
agentRef.current?.abort();
|
|
620
|
-
exit();
|
|
141
|
+
useTuiKeybinds({
|
|
142
|
+
scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen,
|
|
143
|
+
setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef,
|
|
144
|
+
setScrollOffset, exit,
|
|
621
145
|
});
|
|
622
146
|
// Submit an initial task passed on the command line once, after mount.
|
|
623
147
|
const bootRef = useRef(false);
|
|
@@ -649,7 +173,10 @@ export function TuiApp({ config, initialTask }) {
|
|
|
649
173
|
// flexShrink={0}: pinned chrome must never be squeezed when the chat
|
|
650
174
|
// content overflows — Ink defaults every box to flexShrink:1, which
|
|
651
175
|
// shrank the input bar to zero rows once the log outgrew the terminal.
|
|
652
|
-
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: scrollTop, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (
|
|
176
|
+
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: scrollTop, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (
|
|
177
|
+
// Welcome: căn giữa cả 2 chiều — height = chiều cao đo được của
|
|
178
|
+
// viewport (flexGrow không giãn được vì content box flexShrink=0).
|
|
179
|
+
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
|
|
653
180
|
}
|
|
654
181
|
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
655
182
|
export async function runTui(config, initialTask) {
|