v0.8.0: WebUI redesign, /web, workspace switch, 75% budget, no shell timeout
The front end is rebuilt on the design tokens the desktop app ships (surfaces, color-mix borders, alpha hovers, accent): a 30-icon inline SVG sprite, dark and light themes, layout switches as deep links (?side=0, ?panel=0, ?theme=light) resolved before the first paint. The empty page is one line and leaves with the first turn; the right rail keeps three cards - todo, file changes (+N -M) and context (tokens, prompt cache hit rate). Both rails animate in and out, and a collapsed rail widens its grid track to 0 instead of being removed, so the transcript never slides into a zero-width column. - src/webui.c: POST /api/workspace - re-root the agent at another directory (permission rules, metrics, AGENTS.md follow) and start a session there, as sessions live per directory; state also reports cached/cache_miss tokens - src/agent_loop.c: cst_agent_set_workspace(), rebuilt prompt and re-measured budget; the auto-compact threshold is 75% of the window (was 60%) - src/tui.c: /web forks a WebUI next to the TUI - separate process and a new session, because cst_webui_run() blocks and two writers corrupt one session - src/os_compat.c, src/os_win.c: run_shell runs without a time limit unless CST_SHELL_TIMEOUT asks for one, so builds and long clones finish - src/main.c, include/cst_webui.h: one CST_WEBUI_DEFAULT_PORT for --help, the docs and /web; Termux opens the browser through termux-open-url - README.md, docs/webui.md, docs/env_openai.md: /web, /api/workspace, the new state fields, the 75% threshold and the layout deep links tests/e2e_webui.sh green (permission round-trip, token paths, --web-dev); make check green.
This commit is contained in:
+40
-4
@@ -2558,9 +2558,9 @@ static int can_continue(const cst_agent_t *a, const turn_t *t,
|
||||
return st->http_status > 0 && st->http_status < 400 && !st->client_stopped;
|
||||
}
|
||||
|
||||
/* Prompt size at which the session is compacted: CST_CTX_BUDGET, else 60% of
|
||||
* the context window capped at 256K tokens. Even a cached prompt is billed
|
||||
* on every step, so huge windows are not worth filling. */
|
||||
/* Prompt size at which the session is compacted: CST_CTX_BUDGET, else 75% of
|
||||
* the context window, capped at 256K tokens. Even a cached prompt is billed on
|
||||
* every step, so a 1M-token window is not worth filling. */
|
||||
static long ctx_budget(const cst_agent_t *a) {
|
||||
const char *e = getenv("CST_CTX_BUDGET");
|
||||
if (e && atol(e) > 0)
|
||||
@@ -2568,7 +2568,7 @@ static long ctx_budget(const cst_agent_t *a) {
|
||||
long win = cst_model_context_tokens(a->cfg.model);
|
||||
if (win <= 0)
|
||||
return 0;
|
||||
long b = win / 10 * 6;
|
||||
long b = win / 4 * 3;
|
||||
return b > 262144 ? 262144 : b;
|
||||
}
|
||||
|
||||
@@ -2948,6 +2948,42 @@ int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_agent_set_workspace(cst_agent_t *a, const char *path, char *err,
|
||||
size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (!a || !path || !path[0]) {
|
||||
snprintf(err, err_sz, "empty path");
|
||||
return -1;
|
||||
}
|
||||
char abs[sizeof(a->cfg.workspace_root)];
|
||||
if (os_realpath(path, abs, sizeof(abs)) != 0) {
|
||||
snprintf(err, err_sz, "no such directory: %s", path);
|
||||
return -1;
|
||||
}
|
||||
/* a file, or a tree we may not write to, fails here instead of halfway
|
||||
* through the next turn */
|
||||
if (cst_agent_store_ensure(abs) != 0) {
|
||||
snprintf(err, err_sz, "cannot use as a workspace: %s", abs);
|
||||
return -1;
|
||||
}
|
||||
snprintf(a->cfg.workspace_root, sizeof(a->cfg.workspace_root), "%s", abs);
|
||||
/* everything that is kept per workspace follows the new root */
|
||||
cst_perms_close(a->perms);
|
||||
a->perms = cst_perms_open(a->cfg.workspace_root);
|
||||
cst_metrics_close(a->metrics);
|
||||
a->metrics = cst_metrics_open(a->cfg.workspace_root,
|
||||
cst_session_path(a->session));
|
||||
cst_agent_reload_notes(a);
|
||||
ensure_system(a); /* the prompt names the directory */
|
||||
a->last_prompt_tokens = 0; /* the old budget was measured elsewhere */
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_agent_switch_session(cst_agent_t *a, const char *id) {
|
||||
if (!a || !id || !id[0])
|
||||
return -1;
|
||||
|
||||
+2
-1
@@ -43,7 +43,7 @@ static void usage(const char *argv0) {
|
||||
" -n, --max-steps N model calls per turn (30)\n"
|
||||
" -H, --headless -p TEXT run one prompt without the TUI\n"
|
||||
" --web serve the UI over HTTP on 127.0.0.1:8787\n"
|
||||
" --web-port N port (0 = pick a free one)\n"
|
||||
" --web-port N port (default 8787, 0 = pick a free one)\n"
|
||||
" --web-host ADDR bind address (default 127.0.0.1; a\n"
|
||||
" non-loopback address requires a token)\n"
|
||||
" --web-token T require ?token=T / Authorization: Bearer T\n"
|
||||
@@ -332,6 +332,7 @@ int main(int argc, char **argv) {
|
||||
int web_mode = 0;
|
||||
cst_webui_opts_t web;
|
||||
memset(&web, 0, sizeof(web));
|
||||
web.port = CST_WEBUI_DEFAULT_PORT; /* 0 would mean "pick a free one" */
|
||||
const char *env = getenv("CST_WEB_PORT");
|
||||
if (env && env[0])
|
||||
web.port = atoi(env);
|
||||
|
||||
+4
-3
@@ -519,7 +519,7 @@ int os_run_timeout_effective(void) {
|
||||
const char *e = getenv("CST_SHELL_TIMEOUT");
|
||||
if (e && atoi(e) > 0)
|
||||
return atoi(e);
|
||||
return 120;
|
||||
return 0; /* no limit: a build, a test suite or a long clone must finish */
|
||||
}
|
||||
|
||||
double os_now_ms(void) {
|
||||
@@ -662,7 +662,8 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
|
||||
int truncated = 0, killed = 0;
|
||||
int status = 0, reaped = 0, bg_killed = 0;
|
||||
double reaped_at = 0, killed_at = 0;
|
||||
double deadline = os_now_ms() + os_run_timeout_effective() * 1000.0;
|
||||
int timeout_s = os_run_timeout_effective();
|
||||
double deadline = timeout_s > 0 ? os_now_ms() + timeout_s * 1000.0 : 0;
|
||||
for (;;) {
|
||||
struct pollfd pfd = {pipefd[0], POLLIN, 0};
|
||||
int pr = poll(&pfd, 1, 100);
|
||||
@@ -692,7 +693,7 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
|
||||
}
|
||||
/* Checked on every pass, so a chatty command still stops promptly. */
|
||||
if (!reaped && !killed &&
|
||||
((g_run_cancel && *g_run_cancel) || now > deadline)) {
|
||||
((g_run_cancel && *g_run_cancel) || (deadline > 0 && now > deadline))) {
|
||||
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1;
|
||||
killed_at = now;
|
||||
kill(-pid, SIGTERM);
|
||||
|
||||
+4
-1
@@ -601,7 +601,10 @@ int os_run_capture_token(const char *cwd, const char *const argv[],
|
||||
buf[0] = 0;
|
||||
int truncated = 0, killed = 0, reaped = 0, bg_killed = 0;
|
||||
double reaped_at = 0, killed_at = 0;
|
||||
double deadline = os_now_ms() + os_run_timeout_effective() * 1000.0;
|
||||
/* no timeout unless one was asked for: a deadline far in the future keeps
|
||||
* every `now > deadline` check working without a second code path */
|
||||
int timeout_s = os_run_timeout_effective();
|
||||
double deadline = timeout_s > 0 ? os_now_ms() + timeout_s * 1000.0 : 1e18;
|
||||
int idle = 0; /* empty polls in a row: wait longer once output stalls */
|
||||
for (;;) {
|
||||
DWORD avail = 0;
|
||||
|
||||
@@ -5364,6 +5364,66 @@ static void cmd_quit(tui_t *t, char *arg, int *quit) {
|
||||
*quit = 1;
|
||||
}
|
||||
|
||||
/* /web: the WebUI is a second front end for the same engine and is designed to
|
||||
* run as its own process, so this starts one on the same workspace and leaves
|
||||
* the TUI holding the terminal. The child gets a fresh session: two writers on
|
||||
* one session file is the one thing that would corrupt it. */
|
||||
#ifndef _WIN32
|
||||
#include <sys/wait.h> /* waitpid: collect the WebUI started by the last /web */
|
||||
#endif
|
||||
#ifndef CST_WEBUI_DEFAULT_PORT
|
||||
#define CST_WEBUI_DEFAULT_PORT 8787 /* keep in sync with include/cst_webui.h */
|
||||
#endif
|
||||
static void cmd_web(tui_t *t, char *arg, int *quit) {
|
||||
(void)arg;
|
||||
(void)quit;
|
||||
#ifdef _WIN32
|
||||
push_system_line(t, "open another terminal and run: cst-agent --web");
|
||||
#else
|
||||
static pid_t spawned = 0;
|
||||
if (spawned > 0) { /* collect the one we started last time */
|
||||
waitpid(spawned, NULL, WNOHANG);
|
||||
spawned = 0;
|
||||
}
|
||||
char dir[4096], exe[4096 + 16];
|
||||
if (os_exe_dir(dir, sizeof(dir)) != 0) {
|
||||
push_system_line(t, "cannot locate this binary: run cst-agent --web yourself");
|
||||
return;
|
||||
}
|
||||
snprintf(exe, sizeof(exe), "%s/cst-agent", dir);
|
||||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||||
const char *ws = (cfg && cfg->workspace_root[0]) ? cfg->workspace_root : ".";
|
||||
char logf[4096];
|
||||
snprintf(logf, sizeof(logf), "%s/.agent/webui.log", ws);
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
push_system_line(t, "cannot start a second process");
|
||||
return;
|
||||
}
|
||||
if (pid == 0) {
|
||||
setsid();
|
||||
int fd = open(logf, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd >= 0) {
|
||||
dup2(fd, STDOUT_FILENO);
|
||||
dup2(fd, STDERR_FILENO);
|
||||
if (fd > 2)
|
||||
close(fd);
|
||||
}
|
||||
execl(exe, "cst-agent", "--web", "--workspace", ws, "--new", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
spawned = pid;
|
||||
char msg[4200];
|
||||
snprintf(msg, sizeof(msg),
|
||||
"WebUI started in the background: http://127.0.0.1:%d/ (new session)",
|
||||
CST_WEBUI_DEFAULT_PORT);
|
||||
push_system_line(t, msg);
|
||||
snprintf(msg, sizeof(msg), "its log: %s (port busy? see the log, or pass "
|
||||
"--web-port)", logf);
|
||||
push_system_line(t, msg);
|
||||
#endif
|
||||
}
|
||||
|
||||
static const slash_cmd_t SLASH[] = {
|
||||
{"help", "?", "", "list commands and keys", 1, cmd_help},
|
||||
{"new", "clear", "", "start a new session", 0, cmd_new},
|
||||
@@ -5422,6 +5482,8 @@ static const slash_cmd_t SLASH[] = {
|
||||
cmd_agents},
|
||||
{"skill", "", "<name> [task]", "run a task with a skill loaded", 0,
|
||||
cmd_skill},
|
||||
{"web", "", "", "start the WebUI in the background (127.0.0.1:8787)", 1,
|
||||
cmd_web},
|
||||
{"quit", "q exit", "", "quit", 1, cmd_quit},
|
||||
};
|
||||
|
||||
|
||||
+39
-1
@@ -181,6 +181,8 @@ static cJSON *wui_state_new(wui_t *w) {
|
||||
cJSON_AddNumberToObject(u, "requests", sess.requests);
|
||||
cJSON_AddNumberToObject(u, "prompt_tokens", (double)sess.prompt);
|
||||
cJSON_AddNumberToObject(u, "completion_tokens", (double)sess.out);
|
||||
cJSON_AddNumberToObject(u, "cached_tokens", (double)sess.cached);
|
||||
cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)sess.miss);
|
||||
cJSON_AddNumberToObject(u, "cost", sess.cost);
|
||||
cJSON_AddBoolToObject(u, "priced", sess.priced != 0);
|
||||
cJSON_AddStringToObject(u, "currency", sess.currency);
|
||||
@@ -916,6 +918,38 @@ static int h_session_new(cst_http_conn_t *c, void *ud) {
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* POST /api/workspace {"path":"/abs/dir"} - re-root the agent. The session
|
||||
* store is per directory, so this always lands in a fresh session there. */
|
||||
static int h_workspace(cst_http_conn_t *c, void *ud) {
|
||||
wui_t *w = ud;
|
||||
const cst_http_req_t *req = cst_http_request(c);
|
||||
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
|
||||
const cJSON *p = j ? cJSON_GetObjectItem(j, "path") : NULL;
|
||||
if (!cJSON_IsString(p) || !p->valuestring[0]) {
|
||||
cJSON_Delete(j);
|
||||
return cst_http_send_err(c, 400, "path is required");
|
||||
}
|
||||
char err[256] = "";
|
||||
int rc = cst_agent_set_workspace(w->agent, p->valuestring, err, sizeof(err));
|
||||
cJSON_Delete(j);
|
||||
if (rc != 0)
|
||||
return cst_http_send_err(c, 400, err[0] ? err : "cannot switch workspace");
|
||||
char id[128] = "";
|
||||
if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0)
|
||||
return cst_http_send_err(c, 500, "cannot start a session there");
|
||||
wui_emit_str(w, "session", "id", id);
|
||||
wui_emit_state(w);
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
if (!o)
|
||||
return cst_http_send_err(c, 500, "out of memory");
|
||||
cJSON_AddStringToObject(o, "id", id);
|
||||
char *json = cJSON_PrintUnformatted(o);
|
||||
cJSON_Delete(o);
|
||||
rc = cst_http_send_json(c, 200, json);
|
||||
free(json);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int h_session_switch(cst_http_conn_t *c, void *ud) {
|
||||
wui_t *w = ud;
|
||||
const cst_http_req_t *req = cst_http_request(c);
|
||||
@@ -1134,7 +1168,10 @@ static void open_browser(const char *url) {
|
||||
os_run_shell_c(".", cmd, 0, NULL);
|
||||
#else
|
||||
char cmd[600];
|
||||
snprintf(cmd, sizeof(cmd), "xdg-open '%s' >/dev/null 2>&1 &", url);
|
||||
/* Termux has no xdg-open; termux-open-url hands the URL to Android. */
|
||||
const char *opener =
|
||||
getenv("TERMUX_VERSION") ? "termux-open-url" : "xdg-open";
|
||||
snprintf(cmd, sizeof(cmd), "%s '%s' >/dev/null 2>&1 &", opener, url);
|
||||
os_run_shell_c(".", cmd, 0, NULL);
|
||||
#endif
|
||||
(void)url;
|
||||
@@ -1173,6 +1210,7 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
|
||||
{"GET", "/api/sessions", h_sessions, NULL, 0, 0},
|
||||
{"POST", "/api/sessions/new", h_session_new, NULL, 0, 0},
|
||||
{"POST", "/api/sessions/switch", h_session_switch, NULL, 0, 0},
|
||||
{"POST", "/api/workspace", h_workspace, NULL, 0, 0},
|
||||
{"POST", "/api/turn", h_turn, NULL, 0, 0},
|
||||
{"POST", "/api/cancel", h_cancel, NULL, 0, 0},
|
||||
{"POST", "/api/permission", h_permission, NULL, 0, 0},
|
||||
|
||||
Reference in New Issue
Block a user