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.
Files changed (112) hide show
  1. package/README.md +615 -638
  2. package/dist/{guardrails.js → agent/guardrails.js} +1 -1
  3. package/dist/agent/interaction.js +70 -0
  4. package/dist/agent/mcp-bridge.js +136 -0
  5. package/dist/agent/prompt-context.js +83 -0
  6. package/dist/agent/run-loop.js +318 -0
  7. package/dist/agent/session-persistence.js +61 -0
  8. package/dist/agent/tool-executor.js +158 -0
  9. package/dist/agent/types.js +88 -0
  10. package/dist/agent/verification.js +99 -0
  11. package/dist/agent.js +239 -1002
  12. package/dist/cli/args.js +230 -0
  13. package/dist/cli/init.js +79 -0
  14. package/dist/cli/main.js +211 -0
  15. package/dist/cli/mcp-command.js +185 -0
  16. package/dist/cli/repl.js +77 -0
  17. package/dist/cli/run-once.js +26 -0
  18. package/dist/{tui/commands.js → commands/definitions.js} +6 -16
  19. package/dist/commands/registry.js +260 -0
  20. package/dist/{config.js → config/config.js} +1 -1
  21. package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
  22. package/dist/{prompt.js → context/prompt.js} +111 -9
  23. package/dist/{skills.js → context/skills.js} +41 -2
  24. package/dist/index.js +5 -798
  25. package/dist/io/console-reporter.js +24 -0
  26. package/dist/io/reporter.js +1 -0
  27. package/dist/mcp/connection.js +227 -0
  28. package/dist/mcp/env.js +8 -0
  29. package/dist/{mcp.js → mcp/manager.js} +3 -515
  30. package/dist/mcp/sse.js +28 -0
  31. package/dist/mcp/transport-http.js +140 -0
  32. package/dist/mcp/transport-stdio.js +98 -0
  33. package/dist/mcp/types.js +11 -0
  34. package/dist/media/image.js +149 -0
  35. package/dist/{server.js → server/server.js} +1 -1
  36. package/dist/{cost.js → session/cost.js} +1 -1
  37. package/dist/{session.js → session/session.js} +1 -1
  38. package/dist/{version.js → shared/version.js} +1 -1
  39. package/dist/tools/apply-patch.js +62 -0
  40. package/dist/tools/edit-file.js +29 -0
  41. package/dist/tools/file-edit-common.js +124 -0
  42. package/dist/tools/list-files.js +64 -0
  43. package/dist/tools/path-safety.js +75 -0
  44. package/dist/tools/read-file.js +64 -0
  45. package/dist/tools/registry.js +297 -0
  46. package/dist/tools/run-command.js +192 -0
  47. package/dist/tools/schemas.js +8 -0
  48. package/dist/tools/search.js +237 -0
  49. package/dist/tools/task-tool.js +48 -0
  50. package/dist/tools/types.js +1 -0
  51. package/dist/tools/write-file.js +34 -0
  52. package/dist/tui/app.js +59 -532
  53. package/dist/tui/ask-user-modal.js +2 -1
  54. package/dist/tui/at-complete.js +2 -93
  55. package/dist/tui/charset.js +41 -0
  56. package/dist/tui/chat-views.js +72 -16
  57. package/dist/tui/chrome.js +11 -5
  58. package/dist/tui/hooks/use-command-host.js +26 -0
  59. package/dist/tui/hooks/use-event-queue.js +69 -0
  60. package/dist/tui/hooks/use-keybinds.js +87 -0
  61. package/dist/tui/hooks/use-scroll.js +38 -0
  62. package/dist/tui/hooks/use-session-resume.js +47 -0
  63. package/dist/tui/hooks/use-submit.js +106 -0
  64. package/dist/tui/image.js +2 -149
  65. package/dist/tui/input.js +1 -1
  66. package/dist/tui/markdown/ast.js +5 -0
  67. package/dist/tui/markdown/diagram.js +239 -0
  68. package/dist/tui/markdown/inline-render.js +24 -0
  69. package/dist/tui/markdown/inline.js +93 -0
  70. package/dist/tui/markdown/parse.js +139 -0
  71. package/dist/tui/markdown/render.js +146 -0
  72. package/dist/tui/markdown/table.js +106 -0
  73. package/dist/tui/markdown.js +32 -379
  74. package/dist/tui/permission-modal.js +2 -1
  75. package/dist/tui/selectors.js +157 -0
  76. package/dist/tui/session-picker.js +4 -3
  77. package/dist/tui/sidebar.js +130 -15
  78. package/dist/tui/state.js +47 -118
  79. package/dist/tui/text-width.js +139 -0
  80. package/dist/tui/theme.js +7 -2
  81. package/dist/{build.js → workspace/build.js} +1 -1
  82. package/dist/workspace/file-index.js +93 -0
  83. package/package.json +5 -1
  84. package/dist/tools.js +0 -1223
  85. package/dist/tui/command-registry.js +0 -477
  86. package/dist/tui/review.js +0 -64
  87. package/dist/tui/transcript.js +0 -145
  88. /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
  89. /package/dist/{agents.js → context/agents.js} +0 -0
  90. /package/dist/{compaction.js → context/compaction.js} +0 -0
  91. /package/dist/{context.js → context/context.js} +0 -0
  92. /package/dist/{memory.js → context/memory.js} +0 -0
  93. /package/dist/{repo-map.js → context/repo-map.js} +0 -0
  94. /package/dist/{ui.js → io/terminal.js} +0 -0
  95. /package/dist/{llm.js → llm/llm.js} +0 -0
  96. /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
  97. /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
  98. /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
  99. /package/dist/{retry.js → llm/retry.js} +0 -0
  100. /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
  101. /package/dist/{permissions.js → security/permissions.js} +0 -0
  102. /package/dist/{sandbox.js → security/sandbox.js} +0 -0
  103. /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
  104. /package/dist/{logger.js → session/logger.js} +0 -0
  105. /package/dist/{steering.js → session/steering.js} +0 -0
  106. /package/dist/{constants.js → shared/constants.js} +0 -0
  107. /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
  108. /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
  109. /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
  110. /package/dist/{diff.js → workspace/diff.js} +0 -0
  111. /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
  112. /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, model/permission badges, build lamp, fuel gauge
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, useBoxMetrics, useInput, useStdin, useWindowSize } from "ink";
16
+ import { Box, Text, useApp, useWindowSize } from "ink";
16
17
  import { Agent } from "../agent.js";
17
- import { setQuiet } from "../ui.js";
18
- import { discoverSkills } from "../skills.js";
19
- import { discoverAgents } from "../agents.js";
20
- import { resolveSessionId } from "../session.js";
21
- import { applyEvent, initialState, markAborted, estimateItemRows } from "./state.js";
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 { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "./mouse.js"; // T5
30
- import { getTheme, setActiveTheme, theme, THEMES, useNamedTheme } from "./theme.js";
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 = useRef(null);
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() below, so it is
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 = useRef(state);
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
- /** Session picker: when a session is selected, resume it. */
194
- const handleSessionSelect = useCallback(async (session) => {
195
- setSessionPickerOpen(false);
196
- if (runningRef.current) {
197
- pushNotice("agent is running — wait or Esc first", "warn");
198
- return;
199
- }
200
- const s = resolveSessionId(cfg.workdir, session.id);
201
- if (!s) {
202
- pushNotice(`session not found: ${session.id}`, "error");
203
- return;
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
- // Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
543
- // the first Esc returns to the bottom instead.
544
- const scrollOffsetRef = useRef(0);
545
- useEffect(() => {
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 ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [_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 agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { dimColor: true, children: ["\u23CE send \u00B7 ", _jsx(Text, { bold: true, children: "ctrl+s" }), " sessions \u00B7 tab complete \u00B7 /help \u00B7 /skills"] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? 42 : 4) }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: "green", bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: "red", bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", 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: "round", 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: 1, paddingBottom: 1, 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 })] }));
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) {