s-app-scoder-agent 0.6.0 → 0.7.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -468
- package/dist/agent.js +825 -175
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +121 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +498 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +182 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
package/dist/tui/app.js
CHANGED
|
@@ -1,225 +1,470 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* Full-screen Ink TUI for scoder — "mission control for the build loop".
|
|
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, build
|
|
7
|
+
* · Build Rail (top): brand, model/permission badges, build lamp, fuel gauge
|
|
8
8
|
* · Flight-recorder log: each tool entry is stamped `07 ▸ name(args)`
|
|
9
9
|
* · `✎` diff cards rendered inline for every file edit
|
|
10
|
+
* · Assistant messages render as markdown (code fences, headers, lists)
|
|
11
|
+
* · Input: ↑/↓ history · Tab command completion · live candidates hint
|
|
10
12
|
* Driven entirely by AgentEvents (the same stream the HTTP/SSE server uses).
|
|
11
13
|
*/
|
|
12
14
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
13
|
-
import { Box, Text, useApp, useInput,
|
|
15
|
+
import { Box, Text, useApp, useBoxMetrics, useInput, useStdin, useWindowSize } from "ink";
|
|
14
16
|
import { Agent } from "../agent.js";
|
|
15
17
|
import { setQuiet } from "../ui.js";
|
|
18
|
+
import { discoverSkills } from "../skills.js";
|
|
19
|
+
import { discoverAgents } from "../agents.js";
|
|
20
|
+
import { resolveSessionId } from "../session.js";
|
|
16
21
|
import { applyEvent, initialState, markAborted } from "./state.js";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
function formatArgs(args) {
|
|
32
|
-
return Object.entries(args)
|
|
33
|
-
.map(([k, v]) => {
|
|
34
|
-
const s = typeof v === "string" ? v : JSON.stringify(v) ?? "";
|
|
35
|
-
return `${k}=${s.length > 60 ? s.slice(0, 60) + "…" : s.replace(/\n/g, " ")}`;
|
|
36
|
-
})
|
|
37
|
-
.join(" ");
|
|
38
|
-
}
|
|
39
|
-
/** Scaled step fuel gauge: ▰ filled / ▱ remaining, e.g. ▰▰▰▰▱▱▱▱ 4/40. */
|
|
40
|
-
function fuelGauge(step, maxSteps) {
|
|
41
|
-
if (maxSteps <= 0)
|
|
42
|
-
return null;
|
|
43
|
-
const filled = Math.min(GAUGE_CELLS, Math.round((Math.min(step, maxSteps) / maxSteps) * GAUGE_CELLS));
|
|
44
|
-
return "▰".repeat(filled) + "▱".repeat(GAUGE_CELLS - filled);
|
|
45
|
-
}
|
|
46
|
-
const OUTPUT_PREVIEW_LINES = 4;
|
|
47
|
-
function ToolItemView({ item, spinner }) {
|
|
48
|
-
const running = item.ok === null;
|
|
49
|
-
const icon = running ? _jsx(Text, { color: "cyan", children: spinner }) : item.ok ? _jsx(Text, { color: "green", children: "\u2714" }) : _jsx(Text, { color: "red", children: "\u2718" });
|
|
50
|
-
const lines = item.output.length > 0 ? item.output.replace(/\n+$/, "").split("\n") : [];
|
|
51
|
-
const preview = lines.slice(-OUTPUT_PREVIEW_LINES);
|
|
52
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8"] }), " ", icon, " ", _jsx(Text, { color: "cyan", children: item.name }), _jsxs(Text, { dimColor: true, children: ["(", formatArgs(item.args), ")"] }), running && _jsx(Text, { dimColor: true, children: " \u00B7 running\u2026" })] }), preview.map((line, i) => (_jsx(Text, { dimColor: i < preview.length - 1, children: line.length > 160 ? line.slice(0, 160) + "…" : line }, i)))] }));
|
|
53
|
-
}
|
|
54
|
-
/** One colored line of a unified diff (+ green, - red, @@ cyan, header bold). */
|
|
55
|
-
function DiffLine({ line }) {
|
|
56
|
-
if (line.startsWith("+++") || line.startsWith("---"))
|
|
57
|
-
return _jsx(Text, { bold: true, children: line });
|
|
58
|
-
if (line.startsWith("@@"))
|
|
59
|
-
return _jsx(Text, { color: "cyan", children: line });
|
|
60
|
-
if (line.startsWith("+"))
|
|
61
|
-
return _jsx(Text, { color: "green", children: line });
|
|
62
|
-
if (line.startsWith("-"))
|
|
63
|
-
return _jsx(Text, { color: "red", children: line });
|
|
64
|
-
return _jsx(Text, { children: line });
|
|
65
|
-
}
|
|
66
|
-
const MAX_DIFF_RENDER_LINES = 24;
|
|
67
|
-
function DiffItemView({ item }) {
|
|
68
|
-
const lines = item.text.length > 0 ? item.text.split("\n") : [];
|
|
69
|
-
const shown = lines.slice(0, MAX_DIFF_RENDER_LINES);
|
|
70
|
-
const hidden = lines.length - shown.length;
|
|
71
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8"] }), " ", _jsx(Text, { color: "magenta", bold: true, children: "\u270E" }), " ", _jsxs(Text, { color: "green", children: ["+", item.added] }), " ", _jsxs(Text, { color: "red", children: ["-", item.removed] }), " ", _jsx(Text, { bold: true, children: item.path }), item.truncated && _jsx(Text, { color: "yellow", children: " (diff truncated)" })] }), shown.map((line, i) => (_jsx(DiffLine, { line: line.length > 160 ? line.slice(0, 160) + "…" : line }, i))), hidden > 0 && _jsxs(Text, { dimColor: true, children: ["\u2026 +", hidden, " more diff lines"] })] }));
|
|
72
|
-
}
|
|
73
|
-
function ChatItemView({ item, spinner }) {
|
|
74
|
-
switch (item.kind) {
|
|
75
|
-
case "user":
|
|
76
|
-
return (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "blue", bold: true, children: ["\u276F", " "] }), _jsx(Text, { bold: true, children: item.text })] }));
|
|
77
|
-
case "assistant":
|
|
78
|
-
return (_jsx(Box, { marginTop: item.text ? 1 : 0, paddingLeft: 1, children: _jsx(Text, { dimColor: item.streaming, children: item.text || (item.streaming ? "" : "(no text)") }) }));
|
|
79
|
-
case "tool":
|
|
80
|
-
return _jsx(ToolItemView, { item: item, spinner: spinner });
|
|
81
|
-
case "diff":
|
|
82
|
-
return _jsx(DiffItemView, { item: item });
|
|
83
|
-
case "build":
|
|
84
|
-
return (_jsxs(Box, { paddingLeft: 1, children: [item.success ? _jsx(Text, { color: "green", bold: true, children: "\u2714 build passed" }) : _jsx(Text, { color: "red", bold: true, children: "\u2718 build failed" }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", item.command] })] }));
|
|
85
|
-
case "notice":
|
|
86
|
-
return (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: item.level === "error" ? "red" : item.level === "warn" ? "yellow" : "gray", children: [item.level === "error" ? "✘ " : "⚠ ", item.text.length > 500 ? item.text.slice(0, 500) + "…" : item.text] }) }));
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
/** Build Rail — scoder's signature top bar: lamp + build command + fuel gauge. */
|
|
90
|
-
function BuildRail({ state, config }) {
|
|
91
|
-
const lampColor = state.buildLamp === "ok" ? "green" : state.buildLamp === "fail" ? "red" : "gray";
|
|
92
|
-
const lampLabel = state.buildLamp === "ok" ? "build ok" : state.buildLamp === "fail" ? "build fail" : "no build yet";
|
|
93
|
-
const gauge = fuelGauge(state.step, state.maxSteps);
|
|
94
|
-
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "magenta", children: "scoder" }), _jsxs(Text, { children: [_jsxs(Text, { color: lampColor, children: ["\u25CF ", lampLabel] }), _jsxs(Text, { dimColor: true, children: [" ", state.buildCmd || config.buildCmd || "auto"] })] }), _jsxs(Text, { children: [gauge && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "magenta", children: gauge }), _jsxs(Text, { dimColor: true, children: [" ", state.step, "/", state.maxSteps] })] })), !gauge && _jsx(Text, { dimColor: true, children: "\u2014 idle" })] })] }));
|
|
95
|
-
}
|
|
96
|
-
function StatusBar({ state, spinner }) {
|
|
97
|
-
const statusColor = state.status === "running" ? "cyan" : state.status === "success" ? "green" : state.status === "failed" ? "red" : "gray";
|
|
98
|
-
const statusLabel = state.status === "running" ? `running ${state.step}/${state.maxSteps}` : state.status === "idle" ? "idle" : state.status;
|
|
99
|
-
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: statusColor, children: state.status === "running" ? `${spinner} ${statusLabel}` : statusLabel }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.model || "—"] }), state.usage.calls > 0 && (_jsxs(Text, { dimColor: true, children: [" ", "\u00B7 tok ", state.usage.inputTokens, "/", state.usage.outputTokens, state.usage.cachedTokens > 0 ? ` (+${state.usage.cachedTokens} cached)` : ""] }))] }), _jsx(Text, { dimColor: true, children: "\u23CE send \u00B7 pgup/pgdn scroll \u00B7 esc abort \u00B7 ctrl+c quit" })] }));
|
|
100
|
-
}
|
|
101
|
-
function Input({ valueRef, value, onChange, onSubmit }) {
|
|
102
|
-
useInput((input, key) => {
|
|
103
|
-
const current = valueRef.current;
|
|
104
|
-
if (key.return) {
|
|
105
|
-
onSubmit(current);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (key.backspace || key.delete) {
|
|
109
|
-
const next = current.slice(0, -1);
|
|
110
|
-
valueRef.current = next;
|
|
111
|
-
onChange(next);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
if (input.length > 0 && !key.ctrl && !key.meta && !key.escape && !key.upArrow && !key.downArrow && !key.tab) {
|
|
115
|
-
// A chunk may contain several pasted chars — even a trailing newline
|
|
116
|
-
// (terminal pastes / PTY writes deliver "exit\r" as one chunk).
|
|
117
|
-
const parts = input.split(/[\r\n]+/);
|
|
118
|
-
const next = current + parts[0];
|
|
119
|
-
valueRef.current = next;
|
|
120
|
-
onChange(next);
|
|
121
|
-
if (parts.length > 1)
|
|
122
|
-
onSubmit(next); // newline in the chunk = submit
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: "magenta", bold: true, children: "❯ " }), _jsx(Text, { children: value }), _jsx(Text, { dimColor: true, children: "\u258C" })] }));
|
|
126
|
-
}
|
|
127
|
-
export function TuiApp({ config, initialTask, bootNotice }) {
|
|
22
|
+
import { parseSlashCommand } from "./commands.js";
|
|
23
|
+
import { dispatchCommand } from "./command-registry.js";
|
|
24
|
+
import { ChatItemView } from "./chat-views.js"; // T2
|
|
25
|
+
import { BuildRail, StatusBar, SIDEBAR_MIN_COLS } from "./chrome.js"; // T2
|
|
26
|
+
import { Input } from "./input.js"; // T2
|
|
27
|
+
import { SessionPicker } from "./session-picker.js"; // session picker modal
|
|
28
|
+
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
|
|
32
|
+
import { PermissionModal } from "./permission-modal.js"; // T2
|
|
33
|
+
import { Sidebar } from "./sidebar.js"; // sidebar opencode
|
|
34
|
+
import { getVersion } from "../version.js";
|
|
35
|
+
export function TuiApp({ config, initialTask }) {
|
|
128
36
|
const { exit } = useApp();
|
|
129
|
-
|
|
37
|
+
// T-09: theme resolve at mount (.scoder/theme.json > builtin). Proxy-based
|
|
38
|
+
// theme reads re-render via a bump.
|
|
39
|
+
const [, bump] = useState(0);
|
|
40
|
+
const bumpFrame = useCallback(() => bump((n) => n + 1), []);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setActiveTheme(getTheme(config.workdir));
|
|
43
|
+
bumpFrame();
|
|
44
|
+
}, [config.workdir, bumpFrame]);
|
|
130
45
|
const [state, setState] = useState(initialState);
|
|
131
46
|
const [input, setInput] = useState(initialTask ?? "");
|
|
132
|
-
|
|
47
|
+
/** Rows scrolled up from the bottom; 0 = follow the newest content. */
|
|
48
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
49
|
+
const [permMode, setPermMode] = useState(config.permissionMode);
|
|
50
|
+
// T-03: active permission prompt (modal) — the run blocks until answered.
|
|
51
|
+
const [permPrompt, setPermPrompt] = useState(null);
|
|
52
|
+
const permPromptRef = useRef(permPrompt);
|
|
53
|
+
permPromptRef.current = permPrompt;
|
|
54
|
+
// Session picker modal state (open via Ctrl+S)
|
|
55
|
+
const [sessionPickerOpen, setSessionPickerOpen] = useState(false);
|
|
133
56
|
const inputRef = useRef(initialTask ?? "");
|
|
134
57
|
const writeInput = useCallback((v) => {
|
|
135
58
|
inputRef.current = v;
|
|
136
59
|
setInput(v);
|
|
137
60
|
}, []);
|
|
61
|
+
const historyRef = useRef([]);
|
|
138
62
|
const runningRef = useRef(false);
|
|
139
63
|
const agentRef = useRef(null);
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
64
|
+
// ── Prompt-gate review flow (/review) ──────────────────────────────────────
|
|
65
|
+
// State machine relaying the universal-prompt-gate-mcp text-fallback flow:
|
|
66
|
+
// call → pending_level_selection → user picks level → pending_user_selection
|
|
67
|
+
// → user picks original/rewritten/update/cancel → approved/cancelled.
|
|
68
|
+
// `argsBase` holds the immutable prompt/context/language; `rewrittenPrompt`
|
|
69
|
+
// is echoed back verbatim from the server's pending result (never from the
|
|
70
|
+
// user or LLM) so only exactly what the user saw can be approved.
|
|
71
|
+
const reviewRef = useRef(null);
|
|
72
|
+
const reviewBusyRef = useRef(false);
|
|
73
|
+
// ── Scrollable chat viewport (Ink 8 alt-screen) ──
|
|
74
|
+
// The viewport clips overflow; the inner content wrapper keeps its natural
|
|
75
|
+
// height so it can be measured and offset upward for scrolling.
|
|
76
|
+
const viewportRef = useRef(null);
|
|
77
|
+
const contentRef = useRef(null);
|
|
78
|
+
const viewport = useBoxMetrics(viewportRef);
|
|
79
|
+
const content = useBoxMetrics(contentRef);
|
|
80
|
+
const viewportRowsRef = useRef(null);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
viewportRowsRef.current = viewport.clientHeight || null;
|
|
83
|
+
}, [viewport.clientHeight]);
|
|
84
|
+
const maxScroll = Math.max(0, content.height - viewport.clientHeight);
|
|
85
|
+
// scrollOffset counts rows UP from the bottom; 0 = follow the newest item.
|
|
86
|
+
const scrollTop = Math.max(0, maxScroll - scrollOffset);
|
|
87
|
+
// T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
|
|
88
|
+
// drops unrepresentable CSI sequences, so wheel events must be read here.
|
|
89
|
+
// Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
|
|
90
|
+
const { stdin, setRawMode, isRawModeSupported } = useStdin();
|
|
91
|
+
const wheelScroll = useCallback((dir) => {
|
|
92
|
+
setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
|
|
93
|
+
}, []);
|
|
144
94
|
useEffect(() => {
|
|
145
|
-
if (
|
|
95
|
+
if (process.stdout.isTTY !== true || !isRawModeSupported)
|
|
146
96
|
return;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
97
|
+
process.stdout.write(MOUSE_ENABLE);
|
|
98
|
+
setRawMode(true);
|
|
99
|
+
const onData = (chunk) => {
|
|
100
|
+
const dir = parseWheel(String(chunk));
|
|
101
|
+
if (dir)
|
|
102
|
+
wheelScroll(dir);
|
|
103
|
+
};
|
|
104
|
+
stdin.on("data", onData);
|
|
105
|
+
return () => {
|
|
106
|
+
stdin.off("data", onData);
|
|
107
|
+
process.stdout.write(MOUSE_DISABLE);
|
|
108
|
+
};
|
|
109
|
+
}, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
|
|
110
|
+
const skills = useMemo(() => discoverSkills(config.workdir), [config.workdir]);
|
|
111
|
+
const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
|
|
112
|
+
// T-02: completion sources (agents + files) computed once per mount.
|
|
113
|
+
const atSources = useMemo(() => ({
|
|
114
|
+
skills: skillNames,
|
|
115
|
+
agents: discoverAgents(config.workdir).filter((a) => a.mode !== "subagent" || a.name === "explore").map((a) => a.name),
|
|
116
|
+
files: fileIndex(config.workdir),
|
|
117
|
+
}), [config.workdir, skillNames]);
|
|
118
|
+
// Mirror of state for the submit path (isFirst, /cost, /model) and the
|
|
119
|
+
// abort handler — kept in sync synchronously by update() below, so it is
|
|
120
|
+
// exact even between renders (event bursts land between them).
|
|
121
|
+
const stateRef = useRef(state);
|
|
122
|
+
/**
|
|
123
|
+
* Central state updater — the single source of truth is stateRef, updated
|
|
124
|
+
* synchronously so event handlers never race a render cycle.
|
|
125
|
+
*/
|
|
126
|
+
const update = useCallback((fn) => {
|
|
127
|
+
stateRef.current = fn(stateRef.current);
|
|
128
|
+
setState(stateRef.current);
|
|
129
|
+
}, []);
|
|
130
|
+
/**
|
|
131
|
+
* Event batching: streaming (`llm_delta`) and live tool output (`tool_output`)
|
|
132
|
+
* can emit hundreds of events per second; each one previously triggered a
|
|
133
|
+
* full-tree re-render (the flicker). Buffer incoming events and flush them
|
|
134
|
+
* through the reducer at most every FLUSH_MS (~20 fps, well above what the
|
|
135
|
+
* eye needs for streaming text).
|
|
136
|
+
*/
|
|
137
|
+
const FLUSH_MS = 50;
|
|
138
|
+
const eventQueue = useRef([]);
|
|
139
|
+
const flushTimer = useRef(null);
|
|
140
|
+
const flushEvents = useCallback(() => {
|
|
141
|
+
flushTimer.current = null;
|
|
142
|
+
const events = eventQueue.current;
|
|
143
|
+
if (events.length === 0)
|
|
144
|
+
return;
|
|
145
|
+
eventQueue.current = [];
|
|
146
|
+
update((s) => {
|
|
147
|
+
let next = s;
|
|
148
|
+
for (const ev of events)
|
|
149
|
+
next = applyEvent(next, ev);
|
|
150
|
+
return { ...next, items: [...next.items] };
|
|
151
|
+
});
|
|
152
|
+
}, [update]);
|
|
153
|
+
const queueEvent = useCallback((ev) => {
|
|
154
|
+
// T-03: permission modal must appear instantly (not batched) — the run
|
|
155
|
+
// blocks on the answer.
|
|
156
|
+
if (ev.type === "permission_request") {
|
|
157
|
+
setPermPrompt(ev);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
eventQueue.current.push(ev);
|
|
161
|
+
if (!flushTimer.current)
|
|
162
|
+
flushTimer.current = setTimeout(flushEvents, FLUSH_MS);
|
|
163
|
+
}, [flushEvents]);
|
|
164
|
+
// Flush on unmount so a trailing burst is never dropped.
|
|
165
|
+
useEffect(() => () => {
|
|
166
|
+
if (flushTimer.current)
|
|
167
|
+
clearTimeout(flushTimer.current);
|
|
168
|
+
flushEvents();
|
|
169
|
+
}, [flushEvents]);
|
|
170
|
+
const pushNotice = useCallback((text, level = "info") => update((s) => ({ ...s, items: [...s.items, { kind: "notice", text, level }] })), [update]);
|
|
171
|
+
/** Session picker: when a session is selected, resume it. */
|
|
172
|
+
const handleSessionSelect = useCallback(async (session) => {
|
|
173
|
+
setSessionPickerOpen(false);
|
|
174
|
+
if (runningRef.current) {
|
|
175
|
+
pushNotice("agent is running — wait or Esc first", "warn");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const s = resolveSessionId(config.workdir, session.id);
|
|
179
|
+
if (!s) {
|
|
180
|
+
pushNotice(`session not found: ${session.id}`, "error");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Restore full TUI state from the session (items + step count + model info).
|
|
184
|
+
update(() => ({
|
|
185
|
+
...initialState(),
|
|
186
|
+
items: s.messages
|
|
187
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
188
|
+
.map((m) => m.role === "user"
|
|
189
|
+
? { kind: "user", text: typeof m.content === "string" ? m.content : "" }
|
|
190
|
+
: { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false }),
|
|
191
|
+
step: s.steps ?? 0,
|
|
192
|
+
maxSteps: s.maxSteps ?? config.maxSteps,
|
|
193
|
+
model: s.model || config.model,
|
|
194
|
+
provider: s.provider || config.provider,
|
|
195
|
+
status: "idle",
|
|
196
|
+
summary: null,
|
|
197
|
+
}));
|
|
198
|
+
runningRef.current = true;
|
|
199
|
+
setScrollOffset(0);
|
|
200
|
+
const agent = createAgent();
|
|
201
|
+
try {
|
|
202
|
+
await agent.resume(s);
|
|
203
|
+
runningRef.current = false;
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
runningRef.current = false;
|
|
207
|
+
pushNotice(`resume failed: ${err.message}`, "error");
|
|
208
|
+
}
|
|
209
|
+
}, [config, createAgent, pushNotice, update]);
|
|
210
|
+
/** T1: CommandHost cho registry dùng chung với REPL. */
|
|
211
|
+
const tuiHost = useCallback(() => ({
|
|
212
|
+
agent: () => agentRef.current,
|
|
213
|
+
config,
|
|
214
|
+
isRunning: () => runningRef.current,
|
|
215
|
+
replaceAgent: (next) => {
|
|
216
|
+
agentRef.current?.abort();
|
|
217
|
+
agentRef.current = next;
|
|
218
|
+
if (!next) {
|
|
219
|
+
runningRef.current = false;
|
|
220
|
+
setScrollOffset(0);
|
|
221
|
+
eventQueue.current = [];
|
|
222
|
+
update(() => initialState());
|
|
223
|
+
}
|
|
224
|
+
setPermMode(config.permissionMode);
|
|
225
|
+
},
|
|
226
|
+
resumeAndRun: async (s) => {
|
|
227
|
+
const agent = createAgent();
|
|
228
|
+
// Replace the pane with the restored transcript, then continue.
|
|
229
|
+
update(() => ({
|
|
230
|
+
...initialState(),
|
|
231
|
+
items: s.messages
|
|
232
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
233
|
+
.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 })),
|
|
234
|
+
}));
|
|
235
|
+
runningRef.current = true;
|
|
236
|
+
setScrollOffset(0);
|
|
237
|
+
try {
|
|
238
|
+
await agent.resume(s);
|
|
239
|
+
runningRef.current = false;
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
runningRef.current = false;
|
|
243
|
+
pushNotice(`resume failed: ${err.message}`, "error");
|
|
244
|
+
}
|
|
245
|
+
return { success: true, steps: 0, buildCmd: config.buildCmd, summary: "resumed" };
|
|
246
|
+
},
|
|
247
|
+
}), [config, update, pushNotice, createAgent]);
|
|
248
|
+
/** One round of the /review state machine: call the gate, render the result. */
|
|
249
|
+
const reviewStep = useCallback(async (tool, args) => {
|
|
250
|
+
const agent = agentRef.current ?? createAgent();
|
|
251
|
+
reviewBusyRef.current = true;
|
|
252
|
+
try {
|
|
253
|
+
const r = await agent.callMcpTool(tool, args);
|
|
254
|
+
const out = reviewFromResponse(reviewRef.current, tool, args, r);
|
|
255
|
+
reviewRef.current = out.state;
|
|
256
|
+
if (out.notice)
|
|
257
|
+
pushNotice(out.notice.text, out.notice.level);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
reviewRef.current = null;
|
|
261
|
+
pushNotice(`prompt gate failed: ${err.message}`, "error");
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
reviewBusyRef.current = false;
|
|
265
|
+
}
|
|
266
|
+
}, [createAgent, pushNotice]);
|
|
267
|
+
/** Handle a user reply while /review is waiting (numbers 1-4 / feedback text). */
|
|
268
|
+
const reviewAnswer = useCallback((text) => {
|
|
269
|
+
const rv = reviewRef.current;
|
|
270
|
+
if (!rv || reviewBusyRef.current)
|
|
271
|
+
return false;
|
|
272
|
+
const out = reviewAnswerToCall(rv, text);
|
|
273
|
+
if (out.notice)
|
|
274
|
+
pushNotice(out.notice.text, out.notice.level);
|
|
275
|
+
if (out.nextCall)
|
|
276
|
+
void reviewStep(out.nextCall.tool, out.nextCall.args);
|
|
277
|
+
return true;
|
|
278
|
+
}, [reviewStep, pushNotice]);
|
|
279
|
+
// Close any lingering streaming messages when the user sends a new query —
|
|
280
|
+
// prevents green/dim text from the previous response appearing stale.
|
|
281
|
+
const closeStreamingItems = useCallback(() => {
|
|
282
|
+
update((s) => {
|
|
283
|
+
let changed = false;
|
|
284
|
+
for (const it of s.items) {
|
|
285
|
+
if (it.kind === "assistant" && it.streaming) {
|
|
286
|
+
it.streaming = false;
|
|
287
|
+
changed = true;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return changed ? { ...s, items: [...s.items] } : s;
|
|
291
|
+
});
|
|
292
|
+
}, [update]);
|
|
293
|
+
/**
|
|
294
|
+
* Shared entry point for every task submission:
|
|
295
|
+
* 1. Close any still-streaming assistant message from the previous response.
|
|
296
|
+
* 2. Record the query in history.
|
|
297
|
+
* 3. Clear the input field.
|
|
298
|
+
* Then the caller handles steer vs. new-run separately.
|
|
299
|
+
*/
|
|
300
|
+
const preSubmit = useCallback((task) => {
|
|
301
|
+
closeStreamingItems();
|
|
302
|
+
historyRef.current = [...historyRef.current, task].slice(-100);
|
|
303
|
+
writeInput("");
|
|
304
|
+
}, [closeStreamingItems, writeInput]);
|
|
152
305
|
const submit = useCallback(async (text) => {
|
|
153
306
|
const task = text.trim();
|
|
154
307
|
if (!task)
|
|
155
308
|
return;
|
|
309
|
+
// Prompt-gate review in progress — numeric replies drive the flow.
|
|
310
|
+
if (reviewRef.current) {
|
|
311
|
+
writeInput("");
|
|
312
|
+
historyRef.current = [...historyRef.current, task].slice(-100);
|
|
313
|
+
reviewAnswer(task);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
156
316
|
// "exit"/"quit" always works — aborts a running agent first.
|
|
157
317
|
if (task === "exit" || task === "quit") {
|
|
158
318
|
agentRef.current?.abort();
|
|
159
319
|
exit();
|
|
160
320
|
return;
|
|
161
321
|
}
|
|
162
|
-
// Slash commands —
|
|
322
|
+
// Slash commands — TUI-specific first (/review /diff /theme /undo),
|
|
323
|
+
// then the shared registry (T1) for the rest.
|
|
163
324
|
const slash = parseSlashCommand(task);
|
|
164
325
|
if (slash) {
|
|
165
326
|
writeInput("");
|
|
166
|
-
if (slash.cmd === "/
|
|
167
|
-
|
|
168
|
-
|
|
327
|
+
if (slash.cmd === "/review") {
|
|
328
|
+
if (!slash.arg) {
|
|
329
|
+
pushNotice("usage: /review <prompt> — rewrites it, you pick original/rewritten/update/cancel", "warn");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!Object.keys(config.mcpServers ?? {}).some((s) => s === "prompt-gate")) {
|
|
333
|
+
pushNotice("no \"prompt-gate\" MCP server configured — add it to ~/.scoder/config.json", "error");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// Context grounded in this session: last user item + current workdir.
|
|
337
|
+
const lastUser = [...stateRef.current.items].reverse().find((i) => i.kind === "user");
|
|
338
|
+
const context = `User is in scoder TUI at ${config.workdir}${lastUser ? `; earlier they asked: "${lastUser.text.slice(0, 120)}"` : ""}.`;
|
|
339
|
+
void reviewStep("mcp__prompt-gate__review_and_select_prompt", {
|
|
340
|
+
prompt: slash.arg,
|
|
341
|
+
context,
|
|
342
|
+
language: "vietnamese",
|
|
343
|
+
});
|
|
169
344
|
return;
|
|
170
345
|
}
|
|
171
|
-
if (slash.cmd === "/
|
|
172
|
-
|
|
346
|
+
if (slash.cmd === "/diff") {
|
|
347
|
+
// T-06: /diff turn | session | branch (default: turn). TUI-specific —
|
|
348
|
+
// turn scope reads the reducer's diff items.
|
|
349
|
+
const scope = slash.arg.trim() || "turn";
|
|
350
|
+
const diffs = stateRef.current.items.filter((i) => i.kind === "diff");
|
|
351
|
+
if (scope === "turn") {
|
|
352
|
+
// Diffs since the last user message (current turn).
|
|
353
|
+
let count = 0;
|
|
354
|
+
const turnDiffs = [];
|
|
355
|
+
for (let i = stateRef.current.items.length - 1; i >= 0; i--) {
|
|
356
|
+
const it = stateRef.current.items[i];
|
|
357
|
+
if (it.kind === "user" && count > 0)
|
|
358
|
+
break;
|
|
359
|
+
if (it.kind === "diff") {
|
|
360
|
+
count++;
|
|
361
|
+
turnDiffs.unshift(`✎ ${it.path} (+${it.added}/-${it.removed})\n${it.text}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
pushNotice(turnDiffs.length ? turnDiffs.join("\n\n") : "no file edits in this turn yet");
|
|
365
|
+
}
|
|
366
|
+
else if (scope === "session") {
|
|
367
|
+
const total = diffs.reduce((a, d) => ({ add: a.add + d.added, rem: a.rem + d.removed }), { add: 0, rem: 0 });
|
|
368
|
+
pushNotice(diffs.length === 0
|
|
369
|
+
? "no file edits this session"
|
|
370
|
+
: diffs.map((d) => `✎ ${d.path} (+${d.added}/-${d.removed})`).join("\n") + `\n${diffs.length} files · +${total.add}/-${total.rem}`);
|
|
371
|
+
}
|
|
372
|
+
else if (scope === "branch") {
|
|
373
|
+
const agent = agentRef.current;
|
|
374
|
+
const snaps = agent?.snapshots.list() ?? [];
|
|
375
|
+
if (!agent || snaps.length === 0) {
|
|
376
|
+
pushNotice("no snapshot checkpoints — /diff branch needs at least one captured edit batch");
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const d = agent.snapshots.diff(snaps[0].tree, null);
|
|
380
|
+
pushNotice(d ? d : "worktree matches the first checkpoint (no net change)");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
pushNotice("usage: /diff [turn|session|branch]", "warn");
|
|
385
|
+
}
|
|
173
386
|
return;
|
|
174
387
|
}
|
|
175
|
-
if (slash.cmd === "/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
388
|
+
if (slash.cmd === "/theme") {
|
|
389
|
+
// T-09: palette hot-swap.
|
|
390
|
+
if (!slash.arg) {
|
|
391
|
+
pushNotice(`themes: ${Object.keys(THEMES).join(", ")} — /theme <name>; project default qua .scoder/theme.json`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
pushNotice(useNamedTheme(slash.arg.trim()), "info");
|
|
395
|
+
bumpFrame();
|
|
182
396
|
return;
|
|
183
397
|
}
|
|
184
|
-
if (slash.cmd === "/
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
398
|
+
if (slash.cmd === "/undo") {
|
|
399
|
+
// T-07: /undo [edit|turn|files]
|
|
400
|
+
// (bare)|files — revert files to the previous checkpoint.
|
|
401
|
+
// edit — files + trim conversation back one turn, then the user
|
|
402
|
+
// can edit and re-send (fork/edit-resend, opencode UX).
|
|
403
|
+
const agent = agentRef.current;
|
|
404
|
+
const mode = slash.arg.trim() || "files";
|
|
405
|
+
const cps = agent?.snapshots.list() ?? [];
|
|
406
|
+
if (!agent || cps.length < 2) {
|
|
407
|
+
pushNotice("undo: no snapshot checkpoint to revert to", "warn");
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (mode !== "files" && mode !== "edit") {
|
|
411
|
+
pushNotice("usage: /undo [files|edit]", "warn");
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// Revert to the second-newest checkpoint (newest = current state).
|
|
415
|
+
const target = cps[cps.length - 2];
|
|
416
|
+
const ok = agent.snapshots.restore(target.tree);
|
|
417
|
+
if (!ok) {
|
|
418
|
+
pushNotice("undo: restore failed", "warn");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (mode === "edit") {
|
|
422
|
+
// Fork: trim the transcript UI back to the last user message and
|
|
423
|
+
// put it in the input for editing + re-send.
|
|
424
|
+
const items = stateRef.current.items;
|
|
425
|
+
let lastUserIdx = -1;
|
|
426
|
+
for (let i = items.length - 1; i >= 0; i--)
|
|
427
|
+
if (items[i].kind === "user") {
|
|
428
|
+
lastUserIdx = i;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
const resend = lastUserIdx >= 0 && items[lastUserIdx].kind === "user" ? items[lastUserIdx].text : "";
|
|
432
|
+
update((s) => ({ ...s, items: lastUserIdx >= 0 ? items.slice(0, lastUserIdx) : s.items, status: "idle", summary: null }));
|
|
433
|
+
if (resend)
|
|
434
|
+
writeInput(resend);
|
|
435
|
+
agent.trimLastTurn();
|
|
436
|
+
pushNotice(`undo edit: files → ${target.id}; last task back in the input — edit + re-send`, "info");
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
pushNotice(`undo: reverted to ${target.id} (${target.label})`);
|
|
440
|
+
}
|
|
190
441
|
return;
|
|
191
442
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
443
|
+
// T1: phần còn lại qua registry dùng chung với REPL.
|
|
444
|
+
const { handled, outcome } = await dispatchCommand(task, tuiHost(), pushNotice);
|
|
445
|
+
if (handled && outcome?.quit) {
|
|
446
|
+
agentRef.current?.abort();
|
|
447
|
+
exit();
|
|
196
448
|
}
|
|
197
|
-
pushNotice(`unknown command ${slash.cmd} — try /help`, "warn");
|
|
198
449
|
return;
|
|
199
450
|
}
|
|
200
|
-
if (runningRef.current)
|
|
201
|
-
|
|
202
|
-
|
|
451
|
+
if (runningRef.current) {
|
|
452
|
+
// Steer: user input while the agent runs — deliver at next step
|
|
453
|
+
// boundary (H-08, "steer by default" — pattern from opencode).
|
|
454
|
+
preSubmit(task);
|
|
455
|
+
agentRef.current?.steer(task);
|
|
456
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "user", text: `${task} (steered)` }] }));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
preSubmit(task);
|
|
203
460
|
runningRef.current = true;
|
|
204
461
|
setScrollOffset(0);
|
|
205
462
|
// isFirst must be computed BEFORE pushing the user item below.
|
|
206
463
|
const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
|
|
207
464
|
// runTurn() doesn't emit run_start, so the TUI shows the user's message
|
|
208
465
|
// itself; applyEvent's run_start dedupe keeps turn 1 from doubling.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (!agent) {
|
|
212
|
-
agent = new Agent(config);
|
|
213
|
-
// Functional updates: React feeds each updater the latest queued
|
|
214
|
-
// state, so events can never land on a stale snapshot (a ref mirror
|
|
215
|
-
// updated in useEffect races events arriving within one render batch
|
|
216
|
-
// — tool results then miss their items and stay "running…" forever).
|
|
217
|
-
agent.onEvent = (ev) => setState((prev) => {
|
|
218
|
-
const next = applyEvent(prev, ev);
|
|
219
|
-
return { ...next, items: [...next.items] };
|
|
220
|
-
});
|
|
221
|
-
agentRef.current = agent;
|
|
222
|
-
}
|
|
466
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
|
|
467
|
+
const agent = createAgent();
|
|
223
468
|
try {
|
|
224
469
|
// First submission runs the full closed loop; later ones continue the session.
|
|
225
470
|
const result = isFirst ? await agent.run(task) : await agent.runTurn(task);
|
|
@@ -228,33 +473,76 @@ export function TuiApp({ config, initialTask, bootNotice }) {
|
|
|
228
473
|
}
|
|
229
474
|
catch (err) {
|
|
230
475
|
runningRef.current = false;
|
|
231
|
-
|
|
476
|
+
// Surface the failure through the reducer (not just a notice) so the
|
|
477
|
+
// status leaves "running" and the error is visible as the final line.
|
|
478
|
+
update((s) => applyEvent({ ...s, items: [...s.items] }, { type: "finish", success: false, steps: s.step, summary: `Run failed: ${err.message}` }));
|
|
479
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: err.message, level: "error" }] }));
|
|
232
480
|
}
|
|
233
|
-
}, [config, pushNotice, writeInput, exit]);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
481
|
+
}, [config, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, reviewAnswer, reviewStep, tuiHost, preSubmit]);
|
|
482
|
+
/** Create (or return) the session agent — permission mode always current. */
|
|
483
|
+
function createAgent() {
|
|
484
|
+
if (agentRef.current)
|
|
485
|
+
return agentRef.current;
|
|
486
|
+
const agent = new Agent({ ...config, permissionMode: permMode });
|
|
487
|
+
// Events go through the batching queue (see FLUSH_MS above): applyEvent
|
|
488
|
+
// still receives the latest state because update() keeps stateRef in
|
|
489
|
+
// sync synchronously — tool results can never miss their items.
|
|
490
|
+
agent.onEvent = queueEvent;
|
|
491
|
+
agentRef.current = agent;
|
|
492
|
+
return agent;
|
|
493
|
+
}
|
|
240
494
|
// Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
|
|
241
495
|
// the first Esc returns to the bottom instead.
|
|
242
496
|
const scrollOffsetRef = useRef(0);
|
|
243
497
|
useEffect(() => {
|
|
244
498
|
scrollOffsetRef.current = scrollOffset;
|
|
245
499
|
}, [scrollOffset]);
|
|
500
|
+
// T-03: permission modal keybinds — intercepts y/a/n/q while open.
|
|
501
|
+
useInput((input, key) => {
|
|
502
|
+
const p = permPromptRef.current;
|
|
503
|
+
if (!p)
|
|
504
|
+
return;
|
|
505
|
+
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;
|
|
506
|
+
if (!answer)
|
|
507
|
+
return;
|
|
508
|
+
setPermPrompt(null);
|
|
509
|
+
agentRef.current?.resolvePermission(p.id, answer);
|
|
510
|
+
});
|
|
511
|
+
// T-01/T-02: scroll + abort keybinds.
|
|
246
512
|
useInput((_input, key) => {
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
513
|
+
if (permPromptRef.current)
|
|
514
|
+
return; // modal owns the keyboard
|
|
515
|
+
// ── Ctrl+S: open session picker (when not running, not already open) ──
|
|
516
|
+
if (key.ctrl && _input === "s") {
|
|
517
|
+
if (!runningRef.current && !sessionPickerOpen) {
|
|
518
|
+
setSessionPickerOpen(true);
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
// T-10: ctrl+t toggles the thinking stream visibility.
|
|
523
|
+
if (key.ctrl && _input === "t") {
|
|
524
|
+
update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (key.pageUp || key.pageDown || key.home || key.end) {
|
|
528
|
+
const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
|
|
529
|
+
if (key.pageUp)
|
|
530
|
+
setScrollOffset((o) => o + page);
|
|
531
|
+
if (key.pageDown)
|
|
532
|
+
setScrollOffset((o) => Math.max(0, o - page));
|
|
533
|
+
if (key.home)
|
|
534
|
+
setScrollOffset(999_999); // top (clamped to maxScroll in render)
|
|
535
|
+
if (key.end)
|
|
536
|
+
setScrollOffset(0); // bottom = follow
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
251
539
|
if (key.escape) {
|
|
252
540
|
if (scrollOffsetRef.current > 0) {
|
|
253
541
|
setScrollOffset(0);
|
|
254
542
|
return;
|
|
255
543
|
}
|
|
256
544
|
if (runningRef.current) {
|
|
257
|
-
|
|
545
|
+
update((s) => ({ ...markAborted(s), items: [...s.items] }));
|
|
258
546
|
agentRef.current?.abort();
|
|
259
547
|
}
|
|
260
548
|
}
|
|
@@ -266,7 +554,7 @@ export function TuiApp({ config, initialTask, bootNotice }) {
|
|
|
266
554
|
return;
|
|
267
555
|
if (runningRef.current && ctrlCArmed.current === null) {
|
|
268
556
|
ctrlCArmed.current = Date.now();
|
|
269
|
-
|
|
557
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
|
|
270
558
|
setTimeout(() => (ctrlCArmed.current = null), 2000);
|
|
271
559
|
return;
|
|
272
560
|
}
|
|
@@ -286,28 +574,40 @@ export function TuiApp({ config, initialTask, bootNotice }) {
|
|
|
286
574
|
return null;
|
|
287
575
|
return state.summary;
|
|
288
576
|
}, [state.status, state.summary]);
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
577
|
+
// When the run ends, jump back to the bottom so the final summary is always
|
|
578
|
+
// visible — a user scrolled up would otherwise never see the conclusion.
|
|
579
|
+
const prevStatusRef = useRef(state.status);
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (prevStatusRef.current === "running" && state.status !== "running")
|
|
582
|
+
setScrollOffset(0);
|
|
583
|
+
prevStatusRef.current = state.status;
|
|
584
|
+
}, [state.status]);
|
|
585
|
+
// The viewport renders items at full height so scroll math (content.height) is
|
|
586
|
+
// always correct. Ink clips with overflow="hidden"; contentOffsetY shifts the
|
|
587
|
+
// content column upward to reveal items above the current bottom.
|
|
588
|
+
const { rows, columns } = useWindowSize();
|
|
589
|
+
const showSidebar = (columns ?? 100) >= SIDEBAR_MIN_COLS;
|
|
590
|
+
return (
|
|
591
|
+
// flexShrink={0}: pinned chrome must never be squeezed when the chat
|
|
592
|
+
// content overflows — Ink defaults every box to flexShrink:1, which
|
|
593
|
+
// shrank the input bar to zero rows once the log outgrew the terminal.
|
|
594
|
+
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: config }), _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 }, 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: config })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: config.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: config, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
|
|
298
595
|
}
|
|
299
596
|
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
300
597
|
export async function runTui(config, initialTask) {
|
|
301
598
|
setQuiet(true);
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
let bootNotice = null;
|
|
305
|
-
if (config.permissionMode === "ask") {
|
|
306
|
-
config = { ...config, permissionMode: "auto" };
|
|
307
|
-
bootNotice = "permission=ask is not supported in the TUI — falling back to auto";
|
|
308
|
-
}
|
|
599
|
+
// T-03: permission=ask works in the TUI — prompts route through the
|
|
600
|
+
// in-pane modal (permission_request events), not stdin readline.
|
|
309
601
|
const { render } = await import("ink");
|
|
310
|
-
|
|
602
|
+
// Alternate screen buffer (vim/htop style): the app owns the whole screen,
|
|
603
|
+
// the BuildRail stays pinned at the top and the input at the bottom while
|
|
604
|
+
// the chat pane scrolls internally. Terminal content is restored on exit.
|
|
605
|
+
// Fallback: terminals without alt-screen support (TERM=dumb, non-TTY pipes,
|
|
606
|
+
// some CI runners) render inline instead — the layout still works.
|
|
607
|
+
const altScreen = process.stdout.isTTY === true && process.env.TERM !== "dumb";
|
|
608
|
+
const { waitUntilExit } = render(_jsx(TuiApp, { config: config, initialTask: initialTask }), {
|
|
609
|
+
alternateScreen: altScreen,
|
|
610
|
+
});
|
|
311
611
|
await waitUntilExit();
|
|
312
612
|
// Ink restores the terminal on unmount; exit explicitly so provider
|
|
313
613
|
// keep-alive sockets (undici pool) can't keep the event loop alive.
|