agentgui 1.0.941 → 1.0.943
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/AGENTS.md +18 -4
- package/CHANGELOG.md +7 -0
- package/lib/agent-descriptors.js +1 -0
- package/lib/agent-discovery.js +1 -0
- package/lib/claude-runner-agents.js +27 -0
- package/lib/claude-runner-direct.js +9 -3
- package/lib/claude-runner.js +22 -1
- package/package.json +1 -1
- package/site/app/index.html +129 -8
- package/site/app/js/app.js +123 -57
package/AGENTS.md
CHANGED
|
@@ -16,13 +16,23 @@ Dependencies:
|
|
|
16
16
|
- `ccsniff` (>=1.1.0) — exports `createHistoryRouter({projectsDir})` mountable on Express; serves `/v1/history/{sessions,sessions/:sid/events,search,snapshot,reindex,stream}`. Reads `~/.claude/projects` (override via `CLAUDE_PROJECTS_DIR`).
|
|
17
17
|
- `anentrypoint-design` (>=0.0.119) — kit library, single-file ESM from unpkg
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Orchestration agents
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The four flagship agents the GUI drives are **Claude Code, OpenCode, Kilo, and Antigravity (`agy`)**; the agent picker (`site/app/js/app.js`, `PRIMARY_AGENTS`) sorts these first, then other-available, then npx-installable, then not-installed.
|
|
22
|
+
|
|
23
|
+
Two runner protocols exist. **Direct** (`lib/claude-runner-direct.js`): claude-code and agy — spawn the CLI per turn, parse stdout. **ACP** (`lib/acp-sdk-manager.js`): opencode/kilo/codex — an on-demand long-lived server on ports 18100/18101/18102, health-checked via `/provider`. The on-demand start + restart-backoff is correct even when an ACP agent lacks provider auth (it reports running-not-healthy rather than crashing).
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
`agy` (Antigravity) is a Gemini-backed Go CLI. Its invocation is `agy --print "<prompt>" --dangerously-skip-permissions [--continue]` — `--print` is a **value flag** (the prompt is its argument; a positional prompt exits 2). It emits **plain text** (not stream-json) and prints no session id, so its `parseOutput` wraps each line into an assistant-text event and resume is `--continue`-only. A live model response needs an authenticated Antigravity session; without it `agy` returns empty (the direct runner resolves gracefully, no hang). Detail in rs-learn (recall "agentgui agy Antigravity agent").
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
**The direct runner spawns with `shell:false` against a resolved binary path — never `shell:true`.** `shell:true` on Windows concatenates argv without escaping, so a chat prompt containing `&`/`|`/`>`/backticks executes as shell commands (arbitrary command execution). `lib/claude-runner.js` `resolveBinaryPath` resolves the command to an absolute `.exe`; `getSpawnOptions` only defaults `shell:true` when a caller passes no explicit `shell`. Detail in rs-learn (recall "agentgui SECURITY direct runner shell:false").
|
|
28
|
+
|
|
29
|
+
**Agent availability comes from `registry.isAvailable(id)`** (`lib/ws-handlers-util.js`, `agents.list`), which runs `where`/`which`. A binary installed outside the system PATH reads as "(not installed)"; the fix is an `npxPackage` on the registry entry so it falls back to bun/npx presence. Detail in rs-learn (recall "agentgui agent availability where npxPackage").
|
|
30
|
+
|
|
31
|
+
**Output and source carry no decorative glyphs.** The GUI uses ASCII words and CSS-drawn discs (`.status-dot-disc`) for status, never `●/◌/○/⌘/§/▶` etc. Convert any decorative glyph to its ASCII equivalent on sight.
|
|
32
|
+
|
|
33
|
+
## Browser Witness
|
|
34
|
+
|
|
35
|
+
`bun server.js`. Default `PORT=3000` (server.js); the SPA is served under `BASE_URL` (default `/gm/`), so the live app is **http://localhost:3000/gm/** — `/health` and `/` answer at root, the app is under `/gm/`. First request to `/gm/` or `/v1/history/*` triggers a 30-90s ccsniff JSONL walk (curl with a short timeout returns 000 during warmup). AppShell renders nav=[chat,history,settings], SSE `hello`, 0 console errors, backend resolves to `''` (same origin).
|
|
26
36
|
|
|
27
37
|
## CI / GitHub Actions
|
|
28
38
|
|
|
@@ -144,3 +154,7 @@ The GUI runs fully offline. `site/app/vendor/cdn/` holds marked, dompurify, pris
|
|
|
144
154
|
- `agents.list` (WS) returns `available` + `npxInstallable` per agent; `agents.models` returns model choices (claude-code → sonnet/opus/haiku). The chat picker is **agent-then-model**, not a flat model list. Unavailable agents are disabled/gated.
|
|
145
155
|
- `chat.sendMessage` accepts `cwd` (defaults to STARTUP_CWD) and `model`/`agentId` separately. `chat.active` (WS) lists in-flight chats with agentId/model/cwd/startedAt/pid; the history tab polls it (3s) and shows a running panel with per-session stop.
|
|
146
156
|
- Client (`app.js`): chat transcript persists to `localStorage[agentgui.chat]` and restores on load; tool_use/result events render as chat parts; keyboard shortcuts (g+c/h/s, n, /, ?); settings has an agents-status panel from `health.acp[]`.
|
|
157
|
+
|
|
158
|
+
## DS CSS cascade — overriding component styles (2026-05-28)
|
|
159
|
+
|
|
160
|
+
**`installStyles()` injects DS CSS into a runtime `<style>` after the head `<style>`, so local overrides need `!important` or higher specificity than the DS's `.ds-247420`-prefixed rules.** Full detail (font vars `--ff-display`/`--ff-mono`, the `.chat-head .sub` "00 msgs" quirk, EventList `.row[role=button]`, `[data-prog-focus]` focus suppression, `projectLabel()` for cwd slugs) is in rs-learn (recall "agentgui GUI styling DS cascade installStyles").
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [Unreleased] - Antigravity (agy) orchestration + glyph-to-ASCII sweep + spawn-injection fix
|
|
2
|
+
|
|
3
|
+
- lib/agent-discovery.js + lib/claude-runner-agents.js + lib/agent-descriptors.js: added `agy` (Antigravity) as a direct-protocol CLI agent — `agy --print "<prompt>" --dangerously-skip-permissions [--continue]`, plain-text parseOutput, no model picker. Completes the four flagship orchestration targets (Claude Code, OpenCode, Kilo, Antigravity). Why: the GUI could not drive Antigravity at all.
|
|
4
|
+
- lib/claude-runner.js + lib/claude-runner-direct.js: SECURITY — the direct runner now spawns a resolved binary with `shell:false`. Previously `shell:true` on Windows concatenated argv unescaped, so a prompt containing `&`/`|`/`>`/backticks executed as shell commands (verified injection). Why: arbitrary command execution from chat input.
|
|
5
|
+
- lib/claude-runner-agents.js: claude-code registry entry gained `npxPackage` so it reports available via npx fallback when `where claude` misses a non-PATH install. Why: the flagship agent showed "(not installed)" despite being present.
|
|
6
|
+
- site/app/js/app.js + site/app/index.html: removed every decorative glyph (status dots, cmd/section/box/arrow/check/cross marks) in favour of ASCII words and a CSS-drawn status disc; agent picker now sorts the four flagship targets first, then available, then npx-installable, then not-installed. Why: machine-shaped glyph tells and a flat 17-item list obscured the orchestration targets.
|
|
7
|
+
|
|
1
8
|
## [Unreleased] - site/app live client migrated to 247420 SDK components
|
|
2
9
|
|
|
3
10
|
- site/app/index.html: removed 35 lines of bespoke CSS overriding design tokens; now uses SDK panel/font tokens with only structural overrides (chat flex layout, history pane grid)
|
package/lib/agent-descriptors.js
CHANGED
|
@@ -2,6 +2,7 @@ const agentDescriptorCache = new Map();
|
|
|
2
2
|
|
|
3
3
|
const AGENT_DESCRIPTIONS = {
|
|
4
4
|
'claude-code': 'Claude Code is an AI coding agent that can read, write, and execute code with streaming output support. It provides comprehensive code editing, file management, and terminal execution capabilities.',
|
|
5
|
+
'agy': 'Antigravity (agy) is a Gemini-backed coding agent CLI. It runs prompts non-interactively with plain-text streaming output, workspace directory scoping, and conversation resume.',
|
|
5
6
|
'gemini': 'Gemini CLI is Google AI coding agent with streaming support, code execution, and file management capabilities.',
|
|
6
7
|
'opencode': 'OpenCode is a multi-provider AI coding agent with streaming support and comprehensive code manipulation capabilities.',
|
|
7
8
|
};
|
package/lib/agent-discovery.js
CHANGED
|
@@ -6,6 +6,7 @@ import { execSync } from 'child_process';
|
|
|
6
6
|
|
|
7
7
|
const BINARIES = [
|
|
8
8
|
{ cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C', protocol: 'cli' },
|
|
9
|
+
{ cmd: 'agy', id: 'agy', name: 'Antigravity', icon: 'Y', protocol: 'cli' },
|
|
9
10
|
{ cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O', protocol: 'acp', npxPackage: 'opencode-ai' },
|
|
10
11
|
{ cmd: 'gemini', id: 'gemini', name: 'Gemini CLI', icon: 'G', protocol: 'acp', npxPackage: '@google/gemini-cli' },
|
|
11
12
|
{ cmd: 'kilo', id: 'kilo', name: 'Kilo Code', icon: 'K', protocol: 'acp', npxPackage: '@kilocode/cli' },
|
|
@@ -97,6 +97,10 @@ function parseClaudeOutput(line) {
|
|
|
97
97
|
registry.register({
|
|
98
98
|
id: 'claude-code', name: 'Claude Code', command: 'claude', protocol: 'direct', supportsStdin: false, closeStdin: true,
|
|
99
99
|
useJsonRpcStdin: false, supportedFeatures: ['streaming', 'resume', 'system-prompt', 'permissions-skip', 'steering'],
|
|
100
|
+
// claude has an npx fallback (matches agent-discovery), so when `where claude`
|
|
101
|
+
// misses a non-PATH install the agent still reports as runnable rather than
|
|
102
|
+
// "(not installed)".
|
|
103
|
+
npxPackage: '@anthropic-ai/claude-code',
|
|
100
104
|
spawnEnv: { MAX_THINKING_TOKENS: '0', AGENTGUI_SUBPROCESS: '1' },
|
|
101
105
|
buildArgs(prompt, config) {
|
|
102
106
|
const { verbose = true, outputFormat = 'stream-json', print = true, resumeSessionId = null, systemPrompt = null, model = null } = config;
|
|
@@ -114,6 +118,29 @@ registry.register({
|
|
|
114
118
|
parseOutput: parseClaudeOutput
|
|
115
119
|
});
|
|
116
120
|
|
|
121
|
+
// Antigravity (`agy`): a direct-protocol CLI like claude-code, but emits PLAIN
|
|
122
|
+
// TEXT (no stream-json, no model flag, no printed session_id). Each stdout line
|
|
123
|
+
// is wrapped into the assistant-text event the non-JSONL stream handler expects.
|
|
124
|
+
// Resume uses --continue (most-recent conversation) since agy prints no session id.
|
|
125
|
+
registry.register({
|
|
126
|
+
id: 'agy', name: 'Antigravity', command: 'agy', protocol: 'direct',
|
|
127
|
+
supportsStdin: false, closeStdin: true,
|
|
128
|
+
supportedFeatures: ['streaming', 'resume'],
|
|
129
|
+
spawnEnv: { AGENTGUI_SUBPROCESS: '1' },
|
|
130
|
+
buildArgs(prompt, config) {
|
|
131
|
+
const { resumeSessionId = null } = config;
|
|
132
|
+
// agy's --print is a VALUE flag (the prompt is its argument), not a boolean
|
|
133
|
+
// followed by a positional. A positional prompt exits with code 2.
|
|
134
|
+
const flags = ['--print', typeof prompt === 'string' ? prompt : String(prompt), '--dangerously-skip-permissions'];
|
|
135
|
+
if (resumeSessionId) flags.push('--continue');
|
|
136
|
+
return flags;
|
|
137
|
+
},
|
|
138
|
+
parseOutput(line) {
|
|
139
|
+
const text = line.replace(/\r$/, '');
|
|
140
|
+
if (!text.trim()) return null;
|
|
141
|
+
return { type: 'assistant', message: { content: [{ type: 'text', text }] } };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
117
144
|
registry.register({ id: 'opencode', name: 'OpenCode', command: 'opencode', protocol: 'acp', supportsStdin: false, npxPackage: 'opencode-ai', supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs: () => ['acp'], protocolHandler: acpProtocolHandler });
|
|
118
145
|
registry.register({ id: 'gemini', name: 'Gemini CLI', command: 'gemini', protocol: 'acp', supportsStdin: false, npxPackage: '@google/gemini-cli', supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs(prompt, config) { const args = ['--experimental-acp', '--yolo']; if (config?.model) args.push('--model', config.model); return args; }, protocolHandler: acpProtocolHandler });
|
|
119
146
|
registry.register({ id: 'goose', name: 'Goose', command: 'goose', protocol: 'acp', supportsStdin: false, supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs: () => ['acp'], protocolHandler: acpProtocolHandler });
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import { AgentRunner, getSpawnOptions } from './claude-runner.js';
|
|
2
|
+
import { AgentRunner, getSpawnOptions, resolveBinaryPath } from './claude-runner.js';
|
|
3
3
|
|
|
4
4
|
AgentRunner.prototype.runDirect = function(prompt, cwd, config = {}) {
|
|
5
5
|
return new Promise((resolve, reject) => {
|
|
6
6
|
const { timeout = 300000, onEvent = null, onError = null, onRateLimit = null } = config;
|
|
7
7
|
const args = this.buildArgs(prompt, config);
|
|
8
|
-
|
|
8
|
+
// The direct runner passes the untrusted user prompt as an argv element.
|
|
9
|
+
// Spawn with shell:false against a resolved binary so the prompt cannot be
|
|
10
|
+
// interpreted by a shell (shell:true would let "& cmd" inject). If the
|
|
11
|
+
// binary resolves to a .cmd/.bat shim, resolveBinaryPath returns the name
|
|
12
|
+
// unchanged and we fall back to the shell (npx-style fallback launchers).
|
|
13
|
+
const binary = resolveBinaryPath(this.command);
|
|
14
|
+
const spawnOpts = getSpawnOptions(cwd, { shell: false });
|
|
9
15
|
if (Object.keys(this.spawnEnv).length > 0) spawnOpts.env = { ...spawnOpts.env, ...this.spawnEnv };
|
|
10
16
|
if (this.closeStdin) spawnOpts.stdio = ['ignore', 'pipe', 'pipe'];
|
|
11
|
-
const proc = spawn(
|
|
17
|
+
const proc = spawn(binary, args, spawnOpts);
|
|
12
18
|
console.log(`[${this.id}] Spawned PID ${proc.pid} closeStdin=${this.closeStdin}`);
|
|
13
19
|
|
|
14
20
|
if (config.onPid) { try { config.onPid(proc.pid); } catch (e) { console.error(`[${this.id}] onPid callback failed:`, e.message); } }
|
package/lib/claude-runner.js
CHANGED
|
@@ -4,12 +4,33 @@ const isWindows = process.platform === 'win32';
|
|
|
4
4
|
|
|
5
5
|
export function getSpawnOptions(cwd, additionalOptions = {}) {
|
|
6
6
|
const options = { cwd, ...additionalOptions };
|
|
7
|
-
|
|
7
|
+
// shell:true on Windows concatenates argv WITHOUT escaping, so a user prompt
|
|
8
|
+
// containing &, |, >, or backticks executes as a shell command (verified
|
|
9
|
+
// injection). Callers that pass untrusted args (the direct runner) must set
|
|
10
|
+
// shell:false and spawn a resolved binary. Default keeps the legacy shell
|
|
11
|
+
// behaviour only when a caller has not already chosen.
|
|
12
|
+
if (isWindows && options.shell === undefined) options.shell = true;
|
|
8
13
|
if (!options.env) options.env = { ...process.env };
|
|
9
14
|
delete options.env.CLAUDECODE;
|
|
10
15
|
return options;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
// Resolve a bare command name to an absolute executable path so it can be
|
|
19
|
+
// spawned with shell:false (no argv concatenation, no injection surface).
|
|
20
|
+
// Returns the original command if resolution fails (Node will still PATH-resolve).
|
|
21
|
+
export function resolveBinaryPath(command) {
|
|
22
|
+
try {
|
|
23
|
+
const whichCmd = isWindows ? 'where' : 'which';
|
|
24
|
+
const r = spawnSync(whichCmd, [command], { encoding: 'utf-8', timeout: 3000 });
|
|
25
|
+
if (r.status === 0) {
|
|
26
|
+
const first = (r.stdout || '').trim().split(/\r?\n/)[0].trim();
|
|
27
|
+
// A .cmd/.bat shim cannot be exec'd without a shell; leave it to the caller.
|
|
28
|
+
if (first && !/\.(cmd|bat)$/i.test(first)) return first;
|
|
29
|
+
}
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
return command;
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
export function resolveCommand(command, npxPackage) {
|
|
14
35
|
const whichCmd = isWindows ? 'where' : 'which';
|
|
15
36
|
const check = spawnSync(whichCmd, [command], { encoding: 'utf-8', timeout: 3000 });
|
package/package.json
CHANGED
package/site/app/index.html
CHANGED
|
@@ -21,11 +21,39 @@
|
|
|
21
21
|
height: 100%;
|
|
22
22
|
background: var(--bg, var(--agentgui-bg));
|
|
23
23
|
color: var(--fg, var(--agentgui-fg));
|
|
24
|
-
|
|
24
|
+
/* The DS exposes --ff-display / --ff-mono (there is no --font-sans), so the
|
|
25
|
+
old reference fell through to the system font. Use the DS display face. */
|
|
26
|
+
font-family: var(--ff-display, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif);
|
|
25
27
|
}
|
|
26
28
|
#app { height: 100vh; height: 100dvh; }
|
|
27
29
|
#app > * { height: 100%; }
|
|
28
30
|
|
|
31
|
+
/* Themed thin scrollbars — the native chrome scrollbar is chunky/light and
|
|
32
|
+
clashes with the dark theme on the history sidebar and settings column. */
|
|
33
|
+
* {
|
|
34
|
+
scrollbar-width: thin;
|
|
35
|
+
scrollbar-color: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 22%, transparent) transparent;
|
|
36
|
+
}
|
|
37
|
+
*::-webkit-scrollbar { width: 9px; height: 9px; }
|
|
38
|
+
*::-webkit-scrollbar-track { background: transparent; }
|
|
39
|
+
*::-webkit-scrollbar-thumb {
|
|
40
|
+
background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 20%, transparent);
|
|
41
|
+
border-radius: 999px;
|
|
42
|
+
border: 2px solid transparent;
|
|
43
|
+
background-clip: padding-box;
|
|
44
|
+
}
|
|
45
|
+
*::-webkit-scrollbar-thumb:hover {
|
|
46
|
+
background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 34%, transparent);
|
|
47
|
+
background-clip: padding-box;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* We move focus to the page heading on tab change for AT users; suppress the
|
|
51
|
+
resulting green outline box on those programmatically-focused elements.
|
|
52
|
+
!important + .ds-247420 prefix to beat the runtime-injected DS focus rule. */
|
|
53
|
+
.ds-247420 [data-prog-focus]:focus,
|
|
54
|
+
.ds-247420 [data-prog-focus]:focus-visible,
|
|
55
|
+
[data-prog-focus]:focus, [data-prog-focus]:focus-visible { outline: none !important; box-shadow: none !important; }
|
|
56
|
+
|
|
29
57
|
/* skip link for keyboard/AT users */
|
|
30
58
|
.skip-link {
|
|
31
59
|
position: absolute; left: -9999px; top: 0; z-index: 1000;
|
|
@@ -35,21 +63,28 @@
|
|
|
35
63
|
}
|
|
36
64
|
.skip-link:focus { left: 8px; top: 8px; outline: 2px solid #fff; outline-offset: 2px; }
|
|
37
65
|
|
|
38
|
-
/* status connection dot
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
/* status connection dot — a CSS-drawn disc (real UI affordance, not a text
|
|
67
|
+
glyph). The .status-dot-disc child is always present; the parent's state
|
|
68
|
+
class colours it, and only is-live pulses. */
|
|
69
|
+
.status-dot { display: inline-flex; align-items: center; gap: .4em; white-space: nowrap; }
|
|
70
|
+
.status-dot-disc {
|
|
71
|
+
flex: none; width: 8px; height: 8px; border-radius: 50%;
|
|
72
|
+
background: var(--muted, #8a8f98);
|
|
73
|
+
}
|
|
74
|
+
.status-dot.is-live .status-dot-disc,
|
|
75
|
+
.status-dot-live .status-dot-disc {
|
|
76
|
+
background: var(--accent, var(--agentgui-accent, #50c878));
|
|
44
77
|
animation: agentgui-pulse 2s infinite;
|
|
45
78
|
}
|
|
79
|
+
.status-dot.is-connecting .status-dot-disc { background: #d9a441; }
|
|
80
|
+
.status-dot.is-offline .status-dot-disc { background: #c0504d; animation: none; }
|
|
46
81
|
@keyframes agentgui-pulse {
|
|
47
82
|
0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
|
|
48
83
|
70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
|
|
49
84
|
100% { box-shadow: 0 0 0 0 rgba(80, 200, 120, 0); }
|
|
50
85
|
}
|
|
51
86
|
@media (prefers-reduced-motion: reduce) {
|
|
52
|
-
.status-dot-
|
|
87
|
+
.status-dot-disc { animation: none !important; }
|
|
53
88
|
}
|
|
54
89
|
|
|
55
90
|
/* resume banner */
|
|
@@ -127,6 +162,92 @@
|
|
|
127
162
|
outline: 2px solid var(--accent, var(--agentgui-accent)); outline-offset: 2px;
|
|
128
163
|
}
|
|
129
164
|
|
|
165
|
+
/* Topbar nav: the DS renders the active tab as a large filled green pill that
|
|
166
|
+
sits taller than the inactive text links and reads as misaligned. Make all
|
|
167
|
+
tabs consistent — equal padding, the active one a subtle tinted underline-pill
|
|
168
|
+
rather than an oversized oval. */
|
|
169
|
+
/* Prefix with .ds-247420 (the <html> class) to match the DS selector's
|
|
170
|
+
specificity; source order then lets these win. */
|
|
171
|
+
.ds-247420 .app-topbar nav { display: flex; align-items: center; gap: .15em; }
|
|
172
|
+
.ds-247420 .app-topbar nav a {
|
|
173
|
+
padding: .35em .75em; border-radius: 8px; line-height: 1.4;
|
|
174
|
+
color: var(--fg-2, color-mix(in srgb, var(--fg, var(--agentgui-fg)) 75%, transparent));
|
|
175
|
+
text-decoration: none; background: transparent;
|
|
176
|
+
transition: background-color .15s ease, color .15s ease;
|
|
177
|
+
}
|
|
178
|
+
.ds-247420 .app-topbar nav a:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 8%, transparent); color: var(--fg, var(--agentgui-fg)); }
|
|
179
|
+
/* installStyles() injects the DS CSS into a runtime <style> appended after
|
|
180
|
+
this block, so equal-specificity rules lose on source order; !important is
|
|
181
|
+
the targeted override for the few props we reshape on the active tab. */
|
|
182
|
+
.ds-247420 .app-topbar nav a.active {
|
|
183
|
+
background: color-mix(in srgb, var(--accent, var(--agentgui-accent)) 16%, transparent) !important;
|
|
184
|
+
color: var(--accent, var(--agentgui-accent)) !important;
|
|
185
|
+
box-shadow: inset 0 -2px 0 0 var(--accent, var(--agentgui-accent)) !important;
|
|
186
|
+
border-radius: 8px !important;
|
|
187
|
+
font-weight: 600;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* Compact working-directory bar (replaces the full-width tall banner box). */
|
|
191
|
+
.cwd-bar {
|
|
192
|
+
display: flex; align-items: center; gap: .5em; flex-wrap: wrap;
|
|
193
|
+
padding: .15em 0 .5em; font-size: .85rem;
|
|
194
|
+
}
|
|
195
|
+
.cwd-bar-text {
|
|
196
|
+
font-family: var(--ff-mono, ui-monospace, monospace);
|
|
197
|
+
color: var(--fg-3, #999); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60vw;
|
|
198
|
+
}
|
|
199
|
+
.cwd-bar-btn {
|
|
200
|
+
cursor: pointer; font: inherit; line-height: 1.3;
|
|
201
|
+
padding: .15em .55em; border-radius: 6px;
|
|
202
|
+
background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 8%, transparent);
|
|
203
|
+
border: 1px solid color-mix(in srgb, var(--fg, var(--agentgui-fg)) 14%, transparent);
|
|
204
|
+
color: var(--fg-2, var(--agentgui-fg));
|
|
205
|
+
}
|
|
206
|
+
.cwd-bar-btn:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 14%, transparent); }
|
|
207
|
+
.cwd-bar-btn:focus-visible { outline: 2px solid var(--accent, var(--agentgui-accent)); outline-offset: 2px; }
|
|
208
|
+
|
|
209
|
+
/* History no-session empty state: fill the void with a centered prompt. */
|
|
210
|
+
.history-empty {
|
|
211
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
212
|
+
gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3, #888);
|
|
213
|
+
}
|
|
214
|
+
.history-empty-glyph { font-size: 3rem; opacity: .25; line-height: 1; }
|
|
215
|
+
.history-empty-title { margin: 0; font-size: 1.05rem; color: var(--fg-2, var(--agentgui-fg)); }
|
|
216
|
+
.history-empty-sub { margin: 0; max-width: 42ch; }
|
|
217
|
+
|
|
218
|
+
/* Settings: two-column on wide screens (backend + agents) so it uses the
|
|
219
|
+
full width instead of a cramped centered measure with empty margins. */
|
|
220
|
+
.settings-grid {
|
|
221
|
+
display: grid; gap: var(--space-4, 16px);
|
|
222
|
+
grid-template-columns: minmax(0, 1fr);
|
|
223
|
+
align-items: start;
|
|
224
|
+
}
|
|
225
|
+
@media (min-width: 900px) {
|
|
226
|
+
.settings-grid { grid-template-columns: minmax(0, 420px) minmax(0, 1fr); }
|
|
227
|
+
}
|
|
228
|
+
/* The DS PageHeader carries large vertical margins that leave a big empty
|
|
229
|
+
band above the heading and between the lede and the panels. Neutralize
|
|
230
|
+
them so settings/history read as normal top-aligned scrolling pages. */
|
|
231
|
+
.agentgui-main [class*="page-header"],
|
|
232
|
+
.agentgui-main .ds-page-header { margin-top: 0 !important; margin-bottom: var(--space-4, 16px) !important; }
|
|
233
|
+
.agentgui-main > :first-child { margin-top: 0 !important; }
|
|
234
|
+
.agentgui-main-settings .settings-grid { margin-top: 0; }
|
|
235
|
+
|
|
236
|
+
/* The DS Chat head computes its own zero-padded count ("00 msgs") and ignores
|
|
237
|
+
our sub prop; it reads as a bug. Hide the DS sub — streaming state shows via
|
|
238
|
+
the title and the busy banner. Also hide the DS's empty decorative head dot. */
|
|
239
|
+
.chat-head .sub { display: none; }
|
|
240
|
+
.chat-head .dot { display: none; }
|
|
241
|
+
|
|
242
|
+
/* EventList rows are role=button (click to expand) but the DS doesn't give
|
|
243
|
+
them a pointer cursor, so the affordance is invisible. */
|
|
244
|
+
.ds-event-list .row[role="button"] { cursor: pointer; }
|
|
245
|
+
.ds-event-list .row[role="button"]:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 5%, transparent); }
|
|
246
|
+
|
|
247
|
+
/* Chat composer: hide the idle scrollbar on the (empty/short) textarea. */
|
|
248
|
+
.chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
|
|
249
|
+
.chat-composer textarea:not(:focus) { overflow-y: hidden; }
|
|
250
|
+
|
|
130
251
|
/* touch targets on small screens */
|
|
131
252
|
@media (max-width: 640px) {
|
|
132
253
|
.pill { min-height: 36px; padding: .4em .8em; }
|
package/site/app/js/app.js
CHANGED
|
@@ -150,12 +150,31 @@ function navTo(tab) {
|
|
|
150
150
|
render();
|
|
151
151
|
// Move focus into the new region for keyboard/AT users.
|
|
152
152
|
requestAnimationFrame(() => {
|
|
153
|
+
syncAriaCurrent();
|
|
153
154
|
const region = document.querySelector('#agentgui-main');
|
|
154
155
|
if (!region) return;
|
|
155
156
|
const heading = region.querySelector('h1, h2');
|
|
156
157
|
const target = heading || region;
|
|
157
158
|
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
|
|
159
|
+
// Mark as programmatically focused so CSS can suppress the focus ring — we
|
|
160
|
+
// move focus here for AT, but a visible green outline box around the heading
|
|
161
|
+
// reads as an accidental border to sighted users.
|
|
162
|
+
target.setAttribute('data-prog-focus', '');
|
|
158
163
|
try { target.focus({ preventScroll: true }); } catch {}
|
|
164
|
+
const clear = () => { target.removeAttribute('data-prog-focus'); target.removeEventListener('blur', clear); };
|
|
165
|
+
target.addEventListener('blur', clear);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The DS Topbar derives aria-current from href↔location.hash matching, which
|
|
170
|
+
// drifts from our hash-based active tab (e.g. aria-current lands on "settings"
|
|
171
|
+
// while we're on "chat"). Re-assert aria-current on the actually-active tab.
|
|
172
|
+
function syncAriaCurrent() {
|
|
173
|
+
const links = document.querySelectorAll('.app-topbar nav a');
|
|
174
|
+
links.forEach((a) => {
|
|
175
|
+
const isActive = a.classList.contains('active');
|
|
176
|
+
if (isActive) a.setAttribute('aria-current', 'page');
|
|
177
|
+
else a.removeAttribute('aria-current');
|
|
159
178
|
});
|
|
160
179
|
}
|
|
161
180
|
|
|
@@ -244,19 +263,20 @@ function closeLiveStream() {
|
|
|
244
263
|
function view() {
|
|
245
264
|
const ok = state.health.status === 'ok';
|
|
246
265
|
const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
|
|
247
|
-
const
|
|
266
|
+
const dotLabel = state.tab === 'history'
|
|
248
267
|
? (state.live.error
|
|
249
|
-
?
|
|
250
|
-
: (liveActive ? '
|
|
251
|
-
: (ok ? (state.health.ws === 'reconnecting' ? '
|
|
268
|
+
? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
|
|
269
|
+
: (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
|
|
270
|
+
: (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
|
|
252
271
|
const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
272
|
+
// The status dot is drawn entirely by CSS (.status-dot::before) — a small
|
|
273
|
+
// colored disc, real product design, not a text glyph. State drives its colour
|
|
274
|
+
// via the modifier class; the label carries only words so AT reads "live", and
|
|
275
|
+
// there are no literal status-glyph characters in the DOM.
|
|
276
|
+
const dotState = state.live.error || (!dotLive) ? 'is-offline'
|
|
277
|
+
: (dotLive && state.tab !== 'history' && state.health.ws === 'reconnecting' ? 'is-connecting' : 'is-live');
|
|
278
|
+
const dot = h('span', { key: 'dot', class: 'status-dot ' + dotState + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
|
|
279
|
+
h('span', { key: 'dd', class: 'status-dot-disc', 'aria-hidden': 'true' }),
|
|
260
280
|
h('span', { key: 'dl' }, dotLabel));
|
|
261
281
|
|
|
262
282
|
const topbar = Topbar({
|
|
@@ -267,11 +287,27 @@ function view() {
|
|
|
267
287
|
onNav: (label) => navTo(label),
|
|
268
288
|
});
|
|
269
289
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
290
|
+
// The four flagship orchestration targets surface first, then the rest of the
|
|
291
|
+
// available agents, then unavailable ones — so the agents the GUI exists to
|
|
292
|
+
// drive are reachable without scanning a flat 17-item list. Ordering only;
|
|
293
|
+
// the DS Select renders a plain option list.
|
|
294
|
+
const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
|
|
295
|
+
const agentRank = (a) => {
|
|
296
|
+
const primary = PRIMARY_AGENTS.indexOf(a.id);
|
|
297
|
+
const avail = a.available !== false;
|
|
298
|
+
if (primary !== -1 && avail) return primary; // 0..3 flagship + available
|
|
299
|
+
if (avail) return 10; // other available
|
|
300
|
+
if (a.npxInstallable) return 20; // installable via npx
|
|
301
|
+
return 30; // not installed
|
|
302
|
+
};
|
|
303
|
+
const agentOptions = state.agents
|
|
304
|
+
.map(a => ({ a, rank: agentRank(a) }))
|
|
305
|
+
.sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
|
|
306
|
+
.map(({ a }) => ({
|
|
307
|
+
value: a.id,
|
|
308
|
+
label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
|
|
309
|
+
disabled: a.available === false && !a.npxInstallable,
|
|
310
|
+
}));
|
|
275
311
|
const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
|
|
276
312
|
|
|
277
313
|
const crumbRight = state.tab === 'chat'
|
|
@@ -295,7 +331,7 @@ function view() {
|
|
|
295
331
|
})
|
|
296
332
|
: null,
|
|
297
333
|
state.chat.busy
|
|
298
|
-
? Btn({ key: 'stop', onClick: cancelChat, children: '
|
|
334
|
+
? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
|
|
299
335
|
: Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
|
|
300
336
|
dot,
|
|
301
337
|
)]
|
|
@@ -314,10 +350,10 @@ function view() {
|
|
|
314
350
|
const side = state.tab === 'history' ? historySide() : null;
|
|
315
351
|
|
|
316
352
|
const agentLabel = state.selectedAgent
|
|
317
|
-
? '
|
|
318
|
-
: '
|
|
353
|
+
? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
|
|
354
|
+
: 'no agent';
|
|
319
355
|
const status = Status({
|
|
320
|
-
left: [state.backend || 'same-origin', ok ? '
|
|
356
|
+
left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
|
|
321
357
|
right: [agentLabel],
|
|
322
358
|
});
|
|
323
359
|
|
|
@@ -331,8 +367,7 @@ function view() {
|
|
|
331
367
|
children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
|
|
332
368
|
: null;
|
|
333
369
|
const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
334
|
-
|
|
335
|
-
return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
|
|
370
|
+
return AppShell({ topbar, crumb, side, main, status, narrow: false });
|
|
336
371
|
}
|
|
337
372
|
|
|
338
373
|
function mainContent() {
|
|
@@ -354,14 +389,14 @@ function toolSummary(block) {
|
|
|
354
389
|
arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
|
|
355
390
|
if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
|
|
356
391
|
}
|
|
357
|
-
return '
|
|
392
|
+
return 'tool: ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
|
|
358
393
|
}
|
|
359
394
|
|
|
360
395
|
function toolResultSummary(block) {
|
|
361
396
|
const c = block?.content ?? block?.output ?? block;
|
|
362
397
|
let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
|
|
363
398
|
s = s.replace(/\s+/g, ' ').trim();
|
|
364
|
-
return (block?.is_error ? '
|
|
399
|
+
return (block?.is_error ? '[error] ' : '') + s.slice(0, 160);
|
|
365
400
|
}
|
|
366
401
|
|
|
367
402
|
function errText(e) {
|
|
@@ -406,8 +441,8 @@ function chatMain() {
|
|
|
406
441
|
const banners = [];
|
|
407
442
|
if (state.chat.resumeSid) {
|
|
408
443
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
409
|
-
h('span', { key: 'rbtxt', class: 'lede' }, '
|
|
410
|
-
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '
|
|
444
|
+
h('span', { key: 'rbtxt', class: 'lede' }, 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
|
|
445
|
+
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
|
|
411
446
|
if (state.chat.resumeNote) {
|
|
412
447
|
banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
|
|
413
448
|
}
|
|
@@ -434,7 +469,14 @@ function chatMain() {
|
|
|
434
469
|
...banners,
|
|
435
470
|
Chat({
|
|
436
471
|
title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
|
|
437
|
-
sub
|
|
472
|
+
// Explicit human-readable sub; the DS default ("NN msgs", zero-padded)
|
|
473
|
+
// leaks an event-list style into chat. Hide it when there are no messages
|
|
474
|
+
// (the empty-state already says "no messages yet").
|
|
475
|
+
sub: state.chat.busy
|
|
476
|
+
? 'streaming…'
|
|
477
|
+
: (state.chat.messages.length
|
|
478
|
+
? state.chat.messages.length + (state.chat.messages.length === 1 ? ' message' : ' messages')
|
|
479
|
+
: ''),
|
|
438
480
|
messages: msgs,
|
|
439
481
|
composer,
|
|
440
482
|
}),
|
|
@@ -459,10 +501,11 @@ function cwdBanner() {
|
|
|
459
501
|
}, children: 'save' }),
|
|
460
502
|
Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
|
|
461
503
|
}
|
|
462
|
-
return h('div', { key: 'cwdb', class: '
|
|
463
|
-
h('span', {
|
|
464
|
-
|
|
465
|
-
|
|
504
|
+
return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
|
|
505
|
+
h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
|
|
506
|
+
state.chatCwd ? 'cwd: ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
|
|
507
|
+
h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
|
|
508
|
+
state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, 'use default') : null);
|
|
466
509
|
}
|
|
467
510
|
|
|
468
511
|
function newChat() {
|
|
@@ -525,7 +568,7 @@ async function sendChat() {
|
|
|
525
568
|
})) {
|
|
526
569
|
if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
|
|
527
570
|
else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
528
|
-
else if (ev.type === 'tool_result') { cur.parts.push('
|
|
571
|
+
else if (ev.type === 'tool_result') { cur.parts.push('-> ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
529
572
|
else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
|
|
530
573
|
else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
|
|
531
574
|
}
|
|
@@ -553,28 +596,36 @@ function reconnectAlert() {
|
|
|
553
596
|
|
|
554
597
|
function historyMain() {
|
|
555
598
|
if (!state.selectedSid) {
|
|
599
|
+
const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
|
|
556
600
|
return [
|
|
557
601
|
reconnectAlert(),
|
|
558
602
|
PageHeader({
|
|
559
|
-
title: '
|
|
603
|
+
title: 'history',
|
|
560
604
|
lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
|
|
561
605
|
}),
|
|
606
|
+
h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
|
|
607
|
+
h('p', { key: 'gt', class: 'history-empty-title' },
|
|
608
|
+
count ? 'Select a session to view its events' : 'No sessions yet'),
|
|
609
|
+
h('p', { key: 'gs', class: 'lede history-empty-sub' },
|
|
610
|
+
count
|
|
611
|
+
? count + ' session' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
|
|
612
|
+
: 'Start a chat or run a local coding agent — its session will appear here live.')),
|
|
562
613
|
].filter(Boolean);
|
|
563
614
|
}
|
|
564
615
|
|
|
565
616
|
const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
|
|
566
617
|
const lede = sess
|
|
567
|
-
? (sess.project || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
|
|
618
|
+
? (projectLabel(sess.project) || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
|
|
568
619
|
: state.selectedSid;
|
|
569
620
|
|
|
570
621
|
const head = PageHeader({
|
|
571
|
-
title:
|
|
622
|
+
title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
|
|
572
623
|
lede,
|
|
573
624
|
});
|
|
574
625
|
|
|
575
626
|
const actions = h('div', { key: 'acts', class: 'history-actions' },
|
|
576
|
-
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '
|
|
577
|
-
Btn({ key: 'copy', onClick: copySid, children: copyToast || '
|
|
627
|
+
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
|
|
628
|
+
Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
|
|
578
629
|
);
|
|
579
630
|
|
|
580
631
|
if (state.events.length === 0) {
|
|
@@ -604,8 +655,8 @@ function historyMain() {
|
|
|
604
655
|
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
|
|
605
656
|
const role = e.role || '?';
|
|
606
657
|
const type = e.type || '?';
|
|
607
|
-
const tool = e.tool ? ' ·
|
|
608
|
-
const errMark = e.isError ? ' ·
|
|
658
|
+
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
659
|
+
const errMark = e.isError ? ' · error' : '';
|
|
609
660
|
const raw = e.text || '';
|
|
610
661
|
const text = raw.replace(/\s+/g, ' ').trim();
|
|
611
662
|
const expanded = state.expandedEvents.has(key);
|
|
@@ -627,9 +678,9 @@ let copyToast = null;
|
|
|
627
678
|
function copySid() {
|
|
628
679
|
const sid = state.selectedSid;
|
|
629
680
|
if (!sid) return;
|
|
630
|
-
const done = () => { copyToast = '
|
|
681
|
+
const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
|
|
631
682
|
if (navigator.clipboard?.writeText) {
|
|
632
|
-
navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = '
|
|
683
|
+
navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
|
|
633
684
|
} else {
|
|
634
685
|
// Fallback for insecure (http) origins where navigator.clipboard is absent.
|
|
635
686
|
try {
|
|
@@ -637,7 +688,7 @@ function copySid() {
|
|
|
637
688
|
ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
638
689
|
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
639
690
|
done();
|
|
640
|
-
} catch { copyToast = '
|
|
691
|
+
} catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
|
|
641
692
|
}
|
|
642
693
|
}
|
|
643
694
|
|
|
@@ -668,6 +719,16 @@ function visibleSessions() {
|
|
|
668
719
|
return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
|
|
669
720
|
}
|
|
670
721
|
|
|
722
|
+
// ccsniff derives `project` from the ~/.claude/projects dir name, which encodes
|
|
723
|
+
// the cwd as a dash-joined path (e.g. "-config-workspace-agentgui"). Show the
|
|
724
|
+
// last meaningful segment ("agentgui") rather than the raw slug.
|
|
725
|
+
function projectLabel(project) {
|
|
726
|
+
if (!project) return '';
|
|
727
|
+
if (/[/\\]/.test(project)) return project.split(/[/\\]/).filter(Boolean).pop() || project;
|
|
728
|
+
const segs = project.split('-').filter(Boolean);
|
|
729
|
+
return segs.length ? segs[segs.length - 1] : project;
|
|
730
|
+
}
|
|
731
|
+
|
|
671
732
|
function uniqueProjects() {
|
|
672
733
|
const arr = Array.isArray(state.sessions) ? state.sessions : [];
|
|
673
734
|
const seen = new Map();
|
|
@@ -699,7 +760,7 @@ function historySide() {
|
|
|
699
760
|
Row({
|
|
700
761
|
key: 'sess' + s.sid,
|
|
701
762
|
rank: String(i + 1).padStart(3, '0'),
|
|
702
|
-
title: (s.isSubagent ? '
|
|
763
|
+
title: (s.isSubagent ? '- ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
|
|
703
764
|
sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
|
|
704
765
|
rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
|
|
705
766
|
active: s.sid === state.selectedSid,
|
|
@@ -714,13 +775,14 @@ function historySide() {
|
|
|
714
775
|
running.length
|
|
715
776
|
? Panel({
|
|
716
777
|
key: 'runningPanel',
|
|
717
|
-
title: '
|
|
778
|
+
title: 'running · ' + running.length,
|
|
718
779
|
children: running.map((r, i) => {
|
|
719
780
|
const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
|
|
720
781
|
const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
|
|
721
782
|
return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
|
|
722
|
-
h('span', {
|
|
723
|
-
|
|
783
|
+
h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
|
|
784
|
+
h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
|
|
785
|
+
Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
|
|
724
786
|
}),
|
|
725
787
|
})
|
|
726
788
|
: null,
|
|
@@ -749,13 +811,13 @@ function historySide() {
|
|
|
749
811
|
? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
|
|
750
812
|
: null,
|
|
751
813
|
state.searchQ
|
|
752
|
-
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '
|
|
814
|
+
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
|
|
753
815
|
: null,
|
|
754
816
|
!searching && projects.length > 1
|
|
755
817
|
? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
|
|
756
818
|
pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
|
|
757
819
|
...projects.slice(0, 8).map(([name, count]) =>
|
|
758
|
-
pillButton('p'+name, truncate(name, 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
|
|
820
|
+
pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
|
|
759
821
|
: null,
|
|
760
822
|
!searching && subagentCount
|
|
761
823
|
? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
|
|
@@ -763,10 +825,10 @@ function historySide() {
|
|
|
763
825
|
'show subagents (' + subagentCount + ')')
|
|
764
826
|
: null,
|
|
765
827
|
state.historyError
|
|
766
|
-
? h('p', { key: 'err', class: 'lede field-error', role: 'alert' },
|
|
828
|
+
? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
|
|
767
829
|
: (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede empty-state' }, 'no sessions yet')),
|
|
768
830
|
!searching && truncatedBy > 0
|
|
769
|
-
? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: '
|
|
831
|
+
? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
|
|
770
832
|
: null,
|
|
771
833
|
],
|
|
772
834
|
}),
|
|
@@ -795,9 +857,8 @@ async function saveBackend() {
|
|
|
795
857
|
function healthSummary() {
|
|
796
858
|
const hh = state.health || {};
|
|
797
859
|
const ok = hh.status === 'ok';
|
|
798
|
-
const dot = ok ? '●' : (hh.status === 'unknown' ? '◌' : '○');
|
|
799
860
|
const bits = [];
|
|
800
|
-
bits.push(
|
|
861
|
+
bits.push(hh.status || 'unknown');
|
|
801
862
|
if (hh.version) bits.push('v' + hh.version);
|
|
802
863
|
if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
|
|
803
864
|
if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
|
|
@@ -811,9 +872,10 @@ function settingsMain() {
|
|
|
811
872
|
const isValid = isValidUrl(state.backendDraft);
|
|
812
873
|
return [
|
|
813
874
|
PageHeader({
|
|
814
|
-
title: '
|
|
875
|
+
title: 'settings',
|
|
815
876
|
lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
|
|
816
877
|
}),
|
|
878
|
+
h('div', { key: 'settings-grid', class: 'settings-grid' }, [
|
|
817
879
|
Panel({
|
|
818
880
|
title: 'backend',
|
|
819
881
|
children: h('form', {
|
|
@@ -830,10 +892,10 @@ function settingsMain() {
|
|
|
830
892
|
title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
|
|
831
893
|
onInput: (v) => { state.backendDraft = v; render(); },
|
|
832
894
|
}),
|
|
833
|
-
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, '
|
|
834
|
-
state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '
|
|
835
|
-
state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '
|
|
836
|
-
state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, '
|
|
895
|
+
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
|
|
896
|
+
state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
897
|
+
state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
|
|
898
|
+
state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
|
|
837
899
|
healthSummary(),
|
|
838
900
|
Btn({
|
|
839
901
|
key: 'savebtn',
|
|
@@ -847,6 +909,7 @@ function settingsMain() {
|
|
|
847
909
|
]),
|
|
848
910
|
}),
|
|
849
911
|
agentsPanel(),
|
|
912
|
+
]),
|
|
850
913
|
];
|
|
851
914
|
}
|
|
852
915
|
|
|
@@ -914,6 +977,8 @@ async function runSearch() {
|
|
|
914
977
|
const debouncedSearch = debounce(runSearch, 300);
|
|
915
978
|
|
|
916
979
|
async function loadSession(sid) {
|
|
980
|
+
// Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
|
|
981
|
+
if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
|
|
917
982
|
state.selectedSid = sid;
|
|
918
983
|
state.events = [];
|
|
919
984
|
state.eventsLoaded = false;
|
|
@@ -984,6 +1049,7 @@ function registerWsStatusOnce() {
|
|
|
984
1049
|
|
|
985
1050
|
restoreChat();
|
|
986
1051
|
render = mount(document.getElementById('app'), view);
|
|
1052
|
+
requestAnimationFrame(syncAriaCurrent);
|
|
987
1053
|
|
|
988
1054
|
// Re-render on resize so isNarrow()/truncate() reflect the current width
|
|
989
1055
|
// (they read window.innerWidth only at render time).
|