claude-code-kanban 7.19.0 → 7.20.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/lib/ask.js ADDED
@@ -0,0 +1,215 @@
1
+ // The ask modal's server side: one open question per terminal, from the plugin's ask tool. The tool's
2
+ // call waits on it, long-polling /wait until the board answers, the user skips, or the agent is
3
+ // interrupted. Nothing is written to disk: a question outlives neither the board nor its terminal.
4
+
5
+ const crypto = require('node:crypto');
6
+ const path = require('node:path');
7
+ const express = require('express');
8
+ const { httpError } = require('./http-error');
9
+
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ const ASK_PATH = '/api/terminals/:terminalId/ask';
12
+ // The answer of a playground card is its whole state.
13
+ const BODY_LIMIT = '256kb';
14
+ const MAX_LABEL = 200;
15
+ const SIZES = ['md', 'lg', 'full'];
16
+ // $.http.fetch gives up after 30 s, so a wait answers pending before that.
17
+ const WAIT_MAX_MS = 25_000;
18
+ // An answer that lands between two polls waits this long for the next one.
19
+ const DONE_TTL_MS = 60_000;
20
+
21
+ function parseAsk(body) {
22
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
23
+ const { sessionId, title, file, size = 'lg' } = body;
24
+ if (typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) return 'invalid sessionId';
25
+ if (typeof title !== 'string' || !title.trim() || title.length > MAX_LABEL) {
26
+ return `title must be a non-empty string of at most ${MAX_LABEL} characters`;
27
+ }
28
+ if (typeof file !== 'string' || !path.isAbsolute(file)) return 'file must be an absolute path';
29
+ if (!SIZES.includes(size)) return `size must be one of ${SIZES.join(', ')}`;
30
+ return { sessionId, title, file, size };
31
+ }
32
+
33
+ // A string is a reason the body is refused.
34
+ function parseAnswer(body) {
35
+ const { status, data } = body || {};
36
+ if (status === 'dismissed') return { status };
37
+ if (status !== 'answered') return 'status must be answered or dismissed';
38
+ if (data === undefined) return 'an answer needs data';
39
+ return { status, data };
40
+ }
41
+
42
+ /**
43
+ * @param {object} [o]
44
+ * @param {() => number} [o.now]
45
+ */
46
+ function createAskStore(o = {}) {
47
+ const now = o.now || Date.now;
48
+ const open = new Map();
49
+ const done = new Map();
50
+ const waiters = new Map();
51
+
52
+ function sweep() {
53
+ const t = now();
54
+ for (const [id, d] of done) if (t - d.at > DONE_TTL_MS) done.delete(id);
55
+ }
56
+
57
+ function settle(ask, result) {
58
+ open.delete(ask.terminalId);
59
+ done.set(ask.id, { result, at: now() });
60
+ for (const w of waiters.get(ask.id) || []) w(result);
61
+ waiters.delete(ask.id);
62
+ }
63
+
64
+ // A new question ends the open one, so the board never holds two.
65
+ function ask(terminalId, { sessionId, title, file, size }) {
66
+ sweep();
67
+ const old = open.get(terminalId);
68
+ if (old) settle(old, { status: 'dismissed', reason: 'replaced' });
69
+ const entry = { id: crypto.randomUUID(), terminalId, sessionId, title, file, size, at: now() };
70
+ open.set(terminalId, entry);
71
+ return { entry, replaced: old?.id ?? null };
72
+ }
73
+
74
+ function current(terminalId) {
75
+ return open.get(terminalId) || null;
76
+ }
77
+
78
+ function end(terminalId, askId, result) {
79
+ const entry = open.get(terminalId);
80
+ if (!entry || entry.id !== askId) return false;
81
+ settle(entry, result);
82
+ return true;
83
+ }
84
+
85
+ // Resolves the result once, or pending after `ms`; an unknown id is gone.
86
+ function wait(askId, ms) {
87
+ const d = done.get(askId);
88
+ if (d) {
89
+ done.delete(askId);
90
+ return Promise.resolve(d.result);
91
+ }
92
+ if (![...open.values()].some((a) => a.id === askId)) return Promise.resolve({ status: 'gone' });
93
+ return new Promise((resolve) => {
94
+ const list = waiters.get(askId) || [];
95
+ const finish = (result) => {
96
+ clearTimeout(timer);
97
+ done.delete(askId);
98
+ resolve(result);
99
+ };
100
+ const timer = setTimeout(() => {
101
+ const left = (waiters.get(askId) || []).filter((w) => w !== finish);
102
+ if (left.length) waiters.set(askId, left);
103
+ else waiters.delete(askId);
104
+ resolve({ status: 'pending' });
105
+ }, ms);
106
+ timer.unref?.();
107
+ list.push(finish);
108
+ waiters.set(askId, list);
109
+ });
110
+ }
111
+
112
+ function prune(keep) {
113
+ for (const [tid, entry] of open) if (!keep(tid)) settle(entry, { status: 'dismissed', reason: 'terminal ended' });
114
+ }
115
+
116
+ return { ask, current, end, wait, prune };
117
+ }
118
+
119
+ function askBodyParser() {
120
+ return express.json({ limit: BODY_LIMIT });
121
+ }
122
+
123
+ /**
124
+ * @param {import('express').Express} app
125
+ * @param {object} o
126
+ * @param {ReturnType<typeof createAskStore>} o.store
127
+ * @param {(token: string|undefined) => boolean} o.authorized the terminal token
128
+ * @param {(terminalId: string) => boolean} o.hasTerminal a live terminal has this id
129
+ * @param {(absPath: string) => Promise<{content: string|null, kind: string|null}>} o.readPreviewFile
130
+ * @param {(event: object) => void} o.broadcast
131
+ */
132
+ function mountAskRoutes(app, o) {
133
+ const { store } = o;
134
+
135
+ function guard(req, res) {
136
+ if (!o.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
137
+ if (!o.hasTerminal(req.params.terminalId)) return res.status(404).json({ error: 'no live terminal with that id' });
138
+ return null;
139
+ }
140
+
141
+ async function readCard(file) {
142
+ let read;
143
+ try {
144
+ read = await o.readPreviewFile(file);
145
+ } catch (e) {
146
+ if (e.status === 404) throw httpError(400, `file not found: ${file}`);
147
+ throw e;
148
+ }
149
+ if (read.kind !== 'html') throw httpError(400, 'file must be an HTML file');
150
+ return read.content;
151
+ }
152
+
153
+ const route = (fn) => (req, res) => fn(req, res).catch((e) => {
154
+ if (res.headersSent) return;
155
+ if (!e.status) console.error(`Error in ${req.method} ${req.path}:`, e);
156
+ res.status(e.status || 500).json({ error: e.status ? e.message : 'Internal error' });
157
+ });
158
+
159
+ const closed = (terminalId, id, status) => o.broadcast({ type: 'ask:closed', terminalId, id, status });
160
+
161
+ app.post(ASK_PATH, route(async (req, res) => {
162
+ if (guard(req, res)) return;
163
+ const p = parseAsk(req.body);
164
+ if (typeof p === 'string') return res.status(400).json({ error: p });
165
+ await readCard(p.file);
166
+ const { terminalId } = req.params;
167
+ const { entry, replaced } = store.ask(terminalId, p);
168
+ if (replaced) closed(terminalId, replaced, 'dismissed');
169
+ o.broadcast({ type: 'ask:opened', terminalId, id: entry.id, sessionId: entry.sessionId });
170
+ res.json({ id: entry.id });
171
+ }));
172
+
173
+ app.get(ASK_PATH, route(async (req, res) => {
174
+ res.setHeader('Cache-Control', 'no-store');
175
+ const entry = store.current(req.params.terminalId);
176
+ if (!entry) return res.json({ ask: null });
177
+ const { id, title, size, file, sessionId } = entry;
178
+ let content = null;
179
+ try {
180
+ content = await readCard(file);
181
+ } catch (e) {
182
+ if (!e.status) throw e;
183
+ }
184
+ res.json({ ask: { id, title, size, file, sessionId, content } });
185
+ }));
186
+
187
+ app.get(`${ASK_PATH}/:askId/wait`, route(async (req, res) => {
188
+ if (guard(req, res)) return;
189
+ const ms = Math.min(Math.max(Number(req.query.ms) || WAIT_MAX_MS, 0), WAIT_MAX_MS);
190
+ res.setHeader('Cache-Control', 'no-store');
191
+ res.json(await store.wait(req.params.askId, ms));
192
+ }));
193
+
194
+ // The board answers or skips.
195
+ app.post(`${ASK_PATH}/:askId`, route(async (req, res) => {
196
+ if (guard(req, res)) return;
197
+ const result = parseAnswer(req.body);
198
+ if (typeof result === 'string') return res.status(400).json({ error: result });
199
+ const { terminalId, askId } = req.params;
200
+ if (!store.end(terminalId, askId, result)) return res.status(404).json({ error: 'no such question' });
201
+ closed(terminalId, askId, result.status);
202
+ res.status(204).end();
203
+ }));
204
+
205
+ // The tool call was interrupted.
206
+ app.delete(`${ASK_PATH}/:askId`, route(async (req, res) => {
207
+ if (guard(req, res)) return;
208
+ const { terminalId, askId } = req.params;
209
+ if (!store.end(terminalId, askId, { status: 'cancelled' })) return res.status(404).json({ error: 'no such question' });
210
+ closed(terminalId, askId, 'cancelled');
211
+ res.status(204).end();
212
+ }));
213
+ }
214
+
215
+ module.exports = { createAskStore, mountAskRoutes, askBodyParser, parseAsk, parseAnswer, ASK_PATH, WAIT_MAX_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.19.0",
3
+ "version": "7.20.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.7.14",
3
+ "version": "3.8.0",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
5
5
  }
@@ -0,0 +1,163 @@
1
+ import type { EngineInterface, Register } from 'claude-code'
2
+ import { cckRoot } from './context'
3
+ import { boardPort } from './show'
4
+
5
+ export const TOOL = 'mcp__claude-code-kanban__ask'
6
+
7
+ type AskInput = { title: string; file: string; size?: 'md' | 'lg' | 'full' }
8
+ type Target = { url: string; terminalId: string }
9
+
10
+ let cck: Promise<string> | undefined
11
+
12
+ function cckDir($: EngineInterface) {
13
+ cck ??= Promise.all([$.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME'), $.env.get('USERPROFILE')]).then(
14
+ ([configDir, home, userProfile]) => cckRoot(configDir, home, userProfile),
15
+ )
16
+ return cck
17
+ }
18
+
19
+ let target: Promise<Target | undefined> | undefined
20
+
21
+ // Both vars come from ptyEnv, so only a session started in cck's embedded terminal has them.
22
+ function terminal($: EngineInterface) {
23
+ target ??= Promise.all([$.env.get('CCK_URL'), $.env.get('CCK_TERMINAL_ID')]).then(([url, terminalId]) =>
24
+ url && terminalId ? { url: url.replace(/\/+$/, ''), terminalId } : undefined,
25
+ )
26
+ return target
27
+ }
28
+
29
+ // Read on every call: a restarted board writes a new token.
30
+ async function terminalToken($: EngineInterface, url: string) {
31
+ const port = boardPort(url)
32
+ if (!port) return undefined
33
+ try {
34
+ const { token } = JSON.parse(await $.fs.read(`${await cckDir($)}/terminal-tokens/${port}.json`))
35
+ return typeof token === 'string' && token ? token : undefined
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ function errorText(status: number, text: string) {
42
+ try {
43
+ const { error } = JSON.parse(text)
44
+ if (typeof error === 'string' && error) return error
45
+ } catch {}
46
+ return `cck answered HTTP ${status}`
47
+ }
48
+
49
+ async function send(
50
+ $: EngineInterface,
51
+ url: string,
52
+ path: string,
53
+ headers: Record<string, string>,
54
+ body: unknown,
55
+ ): Promise<{ text: string } | { deny: string }> {
56
+ try {
57
+ const res = await $.http.fetch(`${url}${path}`, {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/json', ...headers },
60
+ body: JSON.stringify(body),
61
+ })
62
+ return res.ok ? { text: res.text } : { deny: errorText(res.status, res.text) }
63
+ } catch (err) {
64
+ return { deny: `Cannot reach cck at ${url}: ${err instanceof Error ? err.message : String(err)}` }
65
+ }
66
+ }
67
+ type Answer = { status: 'answered'; data: unknown } | { status: 'dismissed' | 'cancelled' | 'gone' | 'pending'; reason?: string }
68
+
69
+ // Under $.http.fetch's 30 s limit; the board answers pending at this mark.
70
+ export const WAIT_MS = 20_000
71
+
72
+ export function answerText(title: string, a: Answer) {
73
+ if (a.status === 'answered') return `The user answered "${title}":\n${JSON.stringify(a.data, null, 2)}`
74
+ if (a.status === 'dismissed' && a.reason === 'replaced') return `"${title}" was replaced by a newer question.`
75
+ if (a.status === 'dismissed') return `The user skipped "${title}" without answering.`
76
+ return `"${title}" is no longer open on the board; the user did not answer it.`
77
+ }
78
+
79
+ const askPath = (t: Target, id = '') =>
80
+ `/api/terminals/${encodeURIComponent(t.terminalId)}/ask${id ? `/${encodeURIComponent(id)}` : ''}`
81
+
82
+ async function poll($: EngineInterface, t: Target, id: string): Promise<Answer | { deny: string }> {
83
+ const token = await terminalToken($, t.url)
84
+ if (!token) return { deny: `No terminal token for the cck server at ${t.url}.` }
85
+ try {
86
+ const res = await $.http.fetch(`${t.url}${askPath(t, id)}/wait?ms=${WAIT_MS}`, {
87
+ headers: { 'x-terminal-token': token },
88
+ })
89
+ if (!res.ok) return { deny: errorText(res.status, res.text) }
90
+ return JSON.parse(res.text) as Answer
91
+ } catch (err) {
92
+ return { deny: `Lost the cck board at ${t.url}: ${err instanceof Error ? err.message : String(err)}` }
93
+ }
94
+ }
95
+
96
+ async function cancel($: EngineInterface, t: Target, id: string) {
97
+ const token = await terminalToken($, t.url)
98
+ if (!token) return
99
+ try {
100
+ await $.http.fetch(`${t.url}${askPath(t, id)}`, { method: 'DELETE', headers: { 'x-terminal-token': token } })
101
+ } catch {}
102
+ }
103
+
104
+ // The call waits until the user answers or skips. An interrupt aborts `signal`: the hook stops at once
105
+ // and closes the modal, because the dispatch has gone on without it.
106
+ async function ask($: EngineInterface, t: Target, input: AskInput, signal: AbortSignal) {
107
+ const token = await terminalToken($, t.url)
108
+ if (!token) return { deny: `No terminal token for the cck server at ${t.url}. It must run with the terminal enabled.` }
109
+ const { title, file, size } = input
110
+ const opened = await send($, t.url, askPath(t), { 'x-terminal-token': token }, {
111
+ sessionId: await $.session.id(),
112
+ title,
113
+ file,
114
+ size,
115
+ })
116
+ if ('deny' in opened) return opened
117
+ const { id } = JSON.parse(opened.text) as { id: string }
118
+ const aborted = new Promise<'aborted'>((resolve) => {
119
+ if (signal.aborted) resolve('aborted')
120
+ signal.addEventListener('abort', () => resolve('aborted'), { once: true })
121
+ })
122
+ for (;;) {
123
+ const got = await Promise.race([poll($, t, id), aborted])
124
+ if (got === 'aborted') {
125
+ await cancel($, t, id)
126
+ return { deny: 'Interrupted; the question was closed.' }
127
+ }
128
+ if ('deny' in got) return got
129
+ if (got.status !== 'pending') return { result: answerText(title, got) }
130
+ }
131
+ }
132
+
133
+ const ASK_TOOL = {
134
+ description:
135
+ 'Ask the user through an HTML card in a modal over this terminal, and wait for the answer, which comes back as the tool result: the data the card sent, as JSON, or a line that says the user skipped. For fixed choices, use AskUserQuestion; use ask when the answer needs something to see or control: a preview, a diagram, sliders, a long form, a quiz sheet, a playground. Load the claude-code-kanban:ask skill first: it says how to write the card and how it sends the answer. Write the card to an HTML file in your scratchpad, then pass it as file.',
136
+ inputSchema: {
137
+ type: 'object',
138
+ properties: {
139
+ title: { type: 'string', description: 'The question, short: it titles the modal and the chip that reopens it' },
140
+ file: { type: 'string', description: 'Absolute path of the HTML card' },
141
+ size: {
142
+ type: 'string',
143
+ enum: ['md', 'lg', 'full'],
144
+ description: 'Modal size: md for a short form, lg (default) for most cards, full for a playground or a quiz sheet',
145
+ },
146
+ },
147
+ required: ['title', 'file'],
148
+ },
149
+ }
150
+
151
+ // Only a session in cck's terminal has a board to draw the modal; elsewhere AskUserQuestion serves.
152
+ export const register: Register = (on) => {
153
+ on('session.start', { isInteractive: true }, async ($, e, next) => {
154
+ if (await terminal($)) await $.tool.register({ name: 'ask', ...ASK_TOOL })
155
+ return next(e)
156
+ })
157
+
158
+ on('tool.call', { tool: TOOL }, async ($, e, next) => {
159
+ const t = await terminal($)
160
+ if (!t) return { deny: 'No cck board here: use AskUserQuestion.' }
161
+ return ask($, t, e as unknown as AskInput, next.signal)
162
+ })
163
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Register } from 'claude-code'
2
2
  import { register as activity } from './activity'
3
+ import { register as ask } from './ask'
3
4
  import { register as context } from './context'
4
5
  import { register as doorbell } from './doorbell'
5
6
  import { register as show } from './show'
@@ -9,4 +10,5 @@ export const register: Register = (on, options) => {
9
10
  activity(on, options)
10
11
  doorbell(on, options)
11
12
  show(on, options)
13
+ ask(on, options)
12
14
  }
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: ask
3
+ description: Ask card guide for the cck ask modal, an HTML question the user answers over the terminal. Cards can be a form, quiz sheet, sliders over a preview, a diagram to pick from, or a playground.
4
+ argument-hint: '[playground|quiz|form] [what to ask]'
5
+ ---
6
+
7
+ # Ask cards
8
+
9
+ The `mcp__claude-code-kanban__ask` tool exists only in a Claude Code session started in cck's terminal. Without it, ask with AskUserQuestion.
10
+
11
+ An argument names what to ask. A first word of `playground`, `quiz` or `form` sets the card kind; for a playground, read [references/playground.md](references/playground.md) and build one. With no argument, ask the open question of this conversation.
12
+
13
+ The tool opens the card as a modal over the terminal and waits. The user's answer comes back as the tool result.
14
+
15
+ ## HTML contract
16
+
17
+ - Write a body fragment to an `.html` file in your scratchpad: no `<!doctype>`, `<html>`, `<head>` or `<body>`. The modal wraps it in a sandboxed document. `<style>` and `<script>` work; the script has no network and no access to the page around it.
18
+ - Pick the size with `size`: `md` 560 px wide for a short form, `lg` 960 px (default) for most cards, `full` the whole terminal for a playground or a quiz sheet. `md` and `lg` take the content's height up to the terminal's, then scroll, so keep their content in normal flow, without `position: fixed` or `100vh`. A `full` card always fills the terminal.
19
+ - Open the card with an `<h2>` that states the question. The modal title is the short form of it.
20
+ - Open links with `<a href="https://...">`; they open in a new browser tab.
21
+ - Take every color and font from these variables, so the card follows the user's light or dark theme:
22
+
23
+ | Variable | Use |
24
+ |---|---|
25
+ | `--color-bg`, `--color-bg-subtle` | Page and panel background |
26
+ | `--color-text`, `--color-text-muted` | Body text, secondary text |
27
+ | `--color-border` | Lines, box outlines |
28
+ | `--color-accent` | The one thing to look at |
29
+ | `--color-info`, `--color-success`, `--color-warning`, `--color-danger` | Status |
30
+ | `--color-series-1` … `--color-series-8` | Chart series, in order |
31
+ | `--color-ramp-1` … `--color-ramp-5` | One-hue magnitude, least to most |
32
+ | `--font-sans`, `--font-mono` | Fonts |
33
+
34
+ ## Sending the answer
35
+
36
+ - From a script: `cck.send(data)`, any JSON value, called from a click or a key press. Use it when the answer is state the card holds, such as a playground's settings or a quiz's picks.
37
+ - From markup: a form `<form data-cck-submit="name">` sends `{action, label, fields}` on submit, with each input's `name` as a field; `required` works. A button `<button data-cck-action="name">` sends its action and label. Action names use letters, digits, `.`, `_` and `-`.
38
+ - Put only the Send button where the card sends: the user's picks are already on screen.
39
+ - Send once, with everything you need: the first answer ends the question. Make each value self-explanatory, so the result reads without the card.
40
+ - A skip comes back as a line that says so. Go on without the answer, and ask in chat only if you cannot.
41
+
42
+ ## Exhibits in the card
43
+
44
+ A picture the user answers about follows the show skill's guides, read before you draw: [chart](../show/references/chart.md), [diagram](../show/references/diagram.md), [mockup](../show/references/mockup.md). Their widths are for the narrower show card; the `size` above sets this one.
@@ -0,0 +1,25 @@
1
+ # Playground
2
+
3
+ A playground is an ask card with controls on one side and a live preview on the other. The user moves the controls until the preview looks right, then sends. The answer is the settings and a sentence that says what they mean. Post it with `size: "full"`.
4
+
5
+ ## Layout
6
+
7
+ - Two columns: controls on the left, about 280 px, and the preview filling the rest. Below 720 px the columns stack, preview first.
8
+ - Group the controls by concern under small headings. Put the rarely used ones in a `<details>` that starts closed.
9
+ - Presets sit above the controls as a row of buttons: 3 to 5 named combinations that look good. The first one is the default, so the card looks right on first load.
10
+ - One Send button closes the controls column. The card has no foot: the preview takes all the height.
11
+
12
+ ## State
13
+
14
+ - Keep every setting in one `state` object, with a frozen `DEFAULTS` beside it.
15
+ - One `draw()` builds the preview from `state`. Every control only writes to `state` and calls `draw()`; there is no Apply button.
16
+ - Declare presets in markup, `<button data-preset='{"radius":8,"shadow":"soft"}'>Soft</button>`, and read them in one place. A new preset is one line of HTML.
17
+ - Keep the computation apart from the drawing, so the preview and the summary read the same numbers.
18
+ - Read colors from the theme variables on each draw, in canvas and SVG too.
19
+
20
+ ## The answer
21
+
22
+ Send calls `cck.send({ summary, state })`:
23
+
24
+ - `summary` is one short sentence, built at send time, that an agent can act on without the card: the last preset picked and only the settings that differ from it, with words next to the numbers ("a soft 8 px shadow"). Keep that preset in `state`, so the summary compares against it, not against `DEFAULTS`.
25
+ - `state` is the full settings object, for exact values.
@@ -0,0 +1,100 @@
1
+ import type { On, ToolCallArgs } from 'claude-code'
2
+ import { expect, mock, test } from 'claude-code/testing'
3
+ import { answerText, TOOL, WAIT_MS } from '../hooks/ask'
4
+
5
+ const CCK_URL = 'http://127.0.0.1:4100'
6
+ const TERMINAL_ID = 'term-1'
7
+ const TOKEN_FILE = 'C:/cfg/.cck/terminal-tokens/4100.json'
8
+ const ASK = `${CCK_URL}/api/terminals/${TERMINAL_ID}/ask`
9
+
10
+ type Reply = { status: number; body: unknown }
11
+ type Request = { url: string; method?: string; headers?: Record<string, string>; body?: unknown }
12
+
13
+ // `replies` answers the ask routes in order; the doorbell's polls never settle.
14
+ function engine(on: On, env: Record<string, string>, replies: Reply[] = []) {
15
+ const registered: string[] = []
16
+ const requests: Request[] = []
17
+ mock.clock(on)
18
+ on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
19
+ on('fs.read', (_$, e) => {
20
+ if (e.path.replaceAll('\\', '/') === TOKEN_FILE) return { value: '{"token":"tok-abc"}' }
21
+ throw new Error(`ENOENT: ${e.path}`)
22
+ })
23
+ on('session.id', () => ({ value: 'sid-1' }))
24
+ on('session.start', (_$, e) => ({ cwd: e.cwd }))
25
+ on('tool.register', (_$, e) => {
26
+ registered.push(e.name)
27
+ return { value: { tool: `mcp__claude-code-kanban__${e.name}` } }
28
+ })
29
+ on('http.fetch', (_$, e) => {
30
+ if (!e.url.includes('/ask')) return new Promise(() => {})
31
+ const { method, headers, body } = (e.init ?? {}) as Omit<Request, 'url'>
32
+ requests.push({ url: e.url, method, headers, body: typeof body === 'string' ? JSON.parse(body) : body })
33
+ const reply = replies.shift()
34
+ if (!reply) throw new Error('no reply')
35
+ return { value: { status: reply.status, ok: reply.status < 300, headers: {}, text: JSON.stringify(reply.body) } }
36
+ })
37
+ return { registered, requests }
38
+ }
39
+
40
+ const GATE = { CCK_URL, CCK_TERMINAL_ID: TERMINAL_ID }
41
+ const call = (input: Record<string, unknown>) => ({ tool: TOOL, ...input }) as ToolCallArgs
42
+ const START = { cwd: 'C:/proj', surface: 'terminal', isInteractive: true } as const
43
+
44
+ test('answer lines', async () => {
45
+ expect(answerText('Pick', { status: 'answered', data: { a: 1 } })).toBe('The user answered "Pick":\n{\n "a": 1\n}')
46
+ expect(answerText('Pick', { status: 'dismissed' })).toBe('The user skipped "Pick" without answering.')
47
+ expect(answerText('Pick', { status: 'dismissed', reason: 'replaced' })).toBe('"Pick" was replaced by a newer question.')
48
+ expect(answerText('Pick', { status: 'gone' })).toContain('no longer open')
49
+ })
50
+
51
+ test('only a session in the terminal gets the tool', async ($, on) => {
52
+ const board = engine(on, { CCK_URL })
53
+ await $.session.start(START)
54
+ expect(board.registered).toEqual(['show'])
55
+ })
56
+
57
+ test('in the terminal: registers ask beside show', async ($, on) => {
58
+ const board = engine(on, GATE)
59
+ await $.session.start(START)
60
+ expect(board.registered).toEqual(['show', 'ask'])
61
+ })
62
+
63
+ test('opens the question, polls past pending, and answers with the data', async ($, on) => {
64
+ const board = engine(on, GATE, [
65
+ { status: 200, body: { id: 'q1' } },
66
+ { status: 200, body: { status: 'pending' } },
67
+ { status: 200, body: { status: 'answered', data: { theme: 'light' } } },
68
+ ])
69
+ await $.session.start(START)
70
+ const res = await $.tool.call(call({ title: 'Pick a theme', file: 'C:/s/ask.html', size: 'full' }))
71
+ expect(res.result).toBe(answerText('Pick a theme', { status: 'answered', data: { theme: 'light' } }))
72
+ const headers = { 'x-terminal-token': 'tok-abc' }
73
+ expect(board.requests).toEqual([
74
+ {
75
+ url: ASK,
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/json', ...headers },
78
+ body: { sessionId: 'sid-1', title: 'Pick a theme', file: 'C:/s/ask.html', size: 'full' },
79
+ },
80
+ { url: `${ASK}/q1/wait?ms=${WAIT_MS}`, method: undefined, headers, body: undefined },
81
+ { url: `${ASK}/q1/wait?ms=${WAIT_MS}`, method: undefined, headers, body: undefined },
82
+ ])
83
+ })
84
+
85
+ test('a skip answers that the user skipped', async ($, on) => {
86
+ engine(on, GATE, [
87
+ { status: 200, body: { id: 'q1' } },
88
+ { status: 200, body: { status: 'dismissed' } },
89
+ ])
90
+ await $.session.start(START)
91
+ const res = await $.tool.call(call({ title: 'Pick', file: 'C:/s/ask.html' }))
92
+ expect(res.result).toBe('The user skipped "Pick" without answering.')
93
+ })
94
+
95
+ test('a board refusal reaches the model with its text', async ($, on) => {
96
+ engine(on, GATE, [{ status: 400, body: { error: 'file must be an HTML file' } }])
97
+ await $.session.start(START)
98
+ const res = await $.tool.call(call({ title: 'Pick', file: 'C:/s/ask.md' }))
99
+ expect(res.deny).toBe('file must be an HTML file')
100
+ })
@@ -56,7 +56,7 @@ test('helpers: the port of CCK_URL, the answer lines', async () => {
56
56
  test('in the terminal: the overlay tool, file optional', async ($, on) => {
57
57
  const board = engine(on, GATE)
58
58
  await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
59
- expect(board.registered).toEqual([{ name: 'show', required: ['title'] }])
59
+ expect(board.registered[0]).toEqual({ name: 'show', required: ['title'] })
60
60
  })
61
61
 
62
62
  test('outside the terminal: the pane tool, file required', async ($, on) => {
package/public/app.js CHANGED
@@ -2808,7 +2808,7 @@ function isAnyModalOpen() {
2808
2808
 
2809
2809
  // An open modal owns the zoom keys even when it has nothing to scale.
2810
2810
  function isZoomableOnScreen() {
2811
- return isAnyModalOpen() ? isZoomableModalOpen() : isPaneDocOnScreen() || isShowCardFocused();
2811
+ return isAnyModalOpen() ? isZoomableModalOpen() : isAskOnScreen() || isPaneDocOnScreen() || isShowCardFocused();
2812
2812
  }
2813
2813
 
2814
2814
  const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
@@ -9073,6 +9073,7 @@ function reviewOptsOfFrame(frame) {
9073
9073
 
9074
9074
  async function sendPreviewAction(frame, msg) {
9075
9075
  if (!navigator.userActivation?.isActive) return;
9076
+ if (frame === askState.frame) return answerAsk({ action: msg.action, label: msg.label, fields: msg.data });
9076
9077
  const opts = reviewOptsOfFrame(frame);
9077
9078
  if (!opts?.sessionId) return;
9078
9079
  const key = `${msg.action}\n${JSON.stringify(msg.data)}`;
@@ -10250,6 +10251,8 @@ function setupEventSource() {
10250
10251
 
10251
10252
  if (data.type === 'show:cleared') onShowCleared(data);
10252
10253
  if (data.type === 'show:removed') onShowRemoved(data);
10254
+ if (data.type === 'ask:opened') onAskOpened(data);
10255
+ if (data.type === 'ask:closed') onAskClosed(data);
10253
10256
 
10254
10257
  if (data.type === 'agent-update') {
10255
10258
  if (!data.unreadOnly) pendingAgentSessionIds.add(data.sessionId);
@@ -15214,9 +15217,10 @@ function inPageField(e) {
15214
15217
  function terminalShortcut(e, probe = false) {
15215
15218
  const ctrlAlt = hubModDown(e);
15216
15219
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
15217
- // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
15220
+ // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal. An open ask card,
15221
+ // which sits in the terminal pane, takes them for its own text zoom.
15218
15222
  const zoom = e.key === '_' ? undefined : zoomDelta(e);
15219
- if (zoom !== undefined && termFrame.inited && (probe || terminalPaneFocused())) {
15223
+ if (zoom !== undefined && termFrame.inited && (probe || (terminalPaneFocused() && !isAskOnScreen()))) {
15220
15224
  return () => adjustTerminalFontSize(Math.sign(zoom));
15221
15225
  }
15222
15226
  if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
@@ -16417,6 +16421,177 @@ window.addEventListener('message', (e) => {
16417
16421
 
16418
16422
  //#endregion
16419
16423
 
16424
+ //#region ASK
16425
+ // The question the session in the embedded terminal asks with the ask tool; the tool call waits for
16426
+ // the answer. Hiding keeps the frame, so a half-filled card comes back as it was. Only an answer, a
16427
+ // skip or the agent's interrupt ends the question.
16428
+ const ASK_MSG = 'cck-ask:';
16429
+ const askState = { terminalId: null, ask: null, frame: null, frameH: 0, hidden: false, busy: false, seq: 0 };
16430
+
16431
+ // Runs inside the card as source text, ahead of the card's own scripts. The JSON round trip makes
16432
+ // the answer cloneable, and a value that is not JSON throws in the card.
16433
+ function askBridge() {
16434
+ window.cck = {
16435
+ send: (data) => parent.postMessage({ type: 'cck-ask:send', data: JSON.parse(JSON.stringify(data ?? null)) }, '*'),
16436
+ };
16437
+ }
16438
+
16439
+ const ASK_BRIDGE_TAG = `<script>(${askBridge})();</script>`;
16440
+
16441
+ function askEl() {
16442
+ return document.getElementById('ask-modal');
16443
+ }
16444
+
16445
+ function isAskOnScreen() {
16446
+ return !!askState.ask && !askEl().hidden;
16447
+ }
16448
+
16449
+ function syncAsk(tid) {
16450
+ if (tid === askState.terminalId) return;
16451
+ askState.terminalId = tid;
16452
+ setAsk(null);
16453
+ if (tid) loadAsk(tid);
16454
+ }
16455
+
16456
+ async function loadAsk(tid) {
16457
+ const seq = ++askState.seq;
16458
+ const data = await getJson(apiPath`/api/terminals/${tid}/ask`, null, { cache: 'no-store' });
16459
+ if (seq !== askState.seq || tid !== askState.terminalId) return;
16460
+ const a = data?.ask ?? null;
16461
+ if (a?.id !== askState.ask?.id) setAsk(a);
16462
+ }
16463
+
16464
+ function setAsk(a) {
16465
+ const modal = askEl();
16466
+ const body = modal.querySelector('.ask-body');
16467
+ Object.assign(askState, { ask: a, frame: null, frameH: 0, hidden: false, busy: false });
16468
+ body.replaceChildren();
16469
+ if (a) {
16470
+ modal.querySelector('.ask-title').textContent = a.title;
16471
+ modal.querySelector('.ask-box').dataset.size = a.size;
16472
+ if (a.content == null) {
16473
+ body.innerHTML = '<p class="ask-missing">The card file is gone. Skip, so the agent stops waiting.</p>';
16474
+ } else {
16475
+ const frame = createPreviewFrame('ask-frame', showSrcdoc(a.content, 0, ASK_BRIDGE_TAG), SHOW_SANDBOX);
16476
+ frame.addEventListener('load', () => {
16477
+ if (askState.frame === frame && !askState.hidden) frame.focus();
16478
+ });
16479
+ askState.frame = frame;
16480
+ body.append(frame);
16481
+ }
16482
+ }
16483
+ renderAsk();
16484
+ }
16485
+
16486
+ function renderAsk() {
16487
+ const a = askState.ask;
16488
+ askEl().hidden = !a || askState.hidden;
16489
+ const chip = document.getElementById('ask-reopen');
16490
+ chip.hidden = !a || !askState.hidden;
16491
+ if (a) {
16492
+ chip.querySelector('.ask-reopen-n').textContent = a.title;
16493
+ chip.title = `Open the question: ${a.title}`;
16494
+ }
16495
+ layoutAsk();
16496
+ }
16497
+
16498
+ // A full card fills the modal; a smaller one takes its content's height, up to the terminal's.
16499
+ function layoutAsk() {
16500
+ const frame = askState.frame;
16501
+ const box = askEl().querySelector('.ask-box');
16502
+ if (!frame || askEl().hidden || box.dataset.size === 'full') return;
16503
+ const host = document.getElementById('terminal-host');
16504
+ const chrome = box.querySelector('.ask-head').offsetHeight + box.querySelector('.ask-foot').offsetHeight + 2;
16505
+ frame.style.height = `${Math.max(SHOW_MIN_H, Math.min(askState.frameH || SHOW_MIN_H, host.clientHeight - 32 - chrome))}px`;
16506
+ }
16507
+
16508
+ function hideAsk() {
16509
+ askState.hidden = true;
16510
+ renderAsk();
16511
+ focusTerminalPane();
16512
+ }
16513
+
16514
+ function reopenAsk() {
16515
+ askState.hidden = false;
16516
+ renderAsk();
16517
+ askState.frame?.focus();
16518
+ }
16519
+
16520
+ async function endAsk(body) {
16521
+ const a = askState.ask;
16522
+ const tid = askState.terminalId;
16523
+ if (!a || !tid || askState.busy) return;
16524
+ askState.busy = true;
16525
+ try {
16526
+ const res = await terminalFetch(apiPath`/api/terminals/${tid}/ask/${a.id}`, 'POST', body);
16527
+ if (res.status === 404) showToast('The question is no longer open');
16528
+ else if (!res.ok) {
16529
+ const out = await res.json().catch(() => ({}));
16530
+ return showToast(out.error || `Send failed (HTTP ${res.status})`, 'error');
16531
+ }
16532
+ if (askState.ask?.id !== a.id) return;
16533
+ setAsk(null);
16534
+ focusTerminalPane();
16535
+ } catch {
16536
+ showToast('Failed to send the answer', 'error');
16537
+ } finally {
16538
+ if (askState.ask?.id === a.id) askState.busy = false;
16539
+ }
16540
+ }
16541
+
16542
+ function answerAsk(data) {
16543
+ return endAsk({ status: 'answered', data });
16544
+ }
16545
+
16546
+ function onAskOpened(d) {
16547
+ if (typeof d.terminalId !== 'string') return;
16548
+ if (d.terminalId === askState.terminalId) return loadAsk(d.terminalId);
16549
+ if (askState.terminalId || !wantsTerminal()) return;
16550
+ if (!terminalIds.has(currentSessionId)) showState.lookup = null;
16551
+ syncShowCard();
16552
+ }
16553
+
16554
+ function onAskClosed(d) {
16555
+ if (d.terminalId !== askState.terminalId || askState.ask?.id !== d.id) return;
16556
+ if (d.status === 'cancelled') showToast('The agent stopped waiting for the answer');
16557
+ setAsk(null);
16558
+ }
16559
+
16560
+ // A script can send without a click, so a send needs transient user activation, as a card action does.
16561
+ window.addEventListener('message', (e) => {
16562
+ const frame = askState.frame;
16563
+ if (!frame || e.source !== frame.contentWindow || typeof e.data?.type !== 'string') return;
16564
+ if (e.data.type === `${ASK_MSG}send`) {
16565
+ if (navigator.userActivation?.isActive) answerAsk(e.data.data);
16566
+ } else if (e.data.type === `${SHOW_MSG}height`) {
16567
+ askState.frameH = Number(e.data.h) || askState.frameH;
16568
+ layoutAsk();
16569
+ }
16570
+ });
16571
+
16572
+ function initAsk() {
16573
+ const modal = askEl();
16574
+ modal.addEventListener('click', (e) => {
16575
+ const cmd = e.target === modal ? 'hide' : e.target.closest('[data-ask]')?.dataset.ask;
16576
+ if (cmd === 'hide') hideAsk();
16577
+ else if (cmd === 'skip') endAsk({ status: 'dismissed' });
16578
+ });
16579
+ document.getElementById('ask-reopen').addEventListener('click', reopenAsk);
16580
+ // Capture, ahead of the board's own Escape. The card's frame hands Escape back as a key on the frame.
16581
+ window.addEventListener(
16582
+ 'keydown',
16583
+ (e) => {
16584
+ if (e.key !== 'Escape' || modal.hidden || !modal.contains(e.target)) return;
16585
+ e.preventDefault();
16586
+ e.stopImmediatePropagation();
16587
+ hideAsk();
16588
+ },
16589
+ true,
16590
+ );
16591
+ window.addEventListener('resize', layoutAsk);
16592
+ }
16593
+ //#endregion
16594
+
16420
16595
  //#region SHOW
16421
16596
  // The card the session in the embedded terminal posts to with the show tool. It belongs to the
16422
16597
  // terminal, not the session: /clear and the --resume picker change the session id, the terminal id
@@ -16629,6 +16804,7 @@ function syncShowCard() {
16629
16804
  loadTerminals().catch(() => {});
16630
16805
  }
16631
16806
  const tid = pty ? (terminalIds.get(pty) ?? null) : null;
16807
+ syncAsk(tid);
16632
16808
  if (tid === showState.terminalId) return;
16633
16809
  const view = (tid && showViews[tid]) || { collapsed: true };
16634
16810
  Object.assign(showState, { terminalId: tid, sessionId: null, idx: 0 });
@@ -16882,8 +17058,8 @@ function withHeadFirst(content, head) {
16882
17058
  const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
16883
17059
 
16884
17060
  // A whole document gets the tokens and base styles first in its head, so its own styles win.
16885
- function showSrcdoc(content, restoreY) {
16886
- const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>`;
17061
+ function showSrcdoc(content, restoreY, extraHead = '') {
17062
+ const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>${extraHead}`;
16887
17063
  const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
16888
17064
  if (!/^\s*(<!doctype|<html)/i.test(content)) {
16889
17065
  return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
@@ -16989,7 +17165,7 @@ new MutationObserver(() => {
16989
17165
 
16990
17166
  // Builds the message only when a frame is there, because showTokens forces a style recalc.
16991
17167
  function postToShowFrames(makeMsg) {
16992
- const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame')].filter(
17168
+ const frames = [showState.frame, ...document.querySelectorAll('.show-pane-frame, .themed-frame, .ask-frame')].filter(
16993
17169
  (f) => f?.contentWindow,
16994
17170
  );
16995
17171
  if (!frames.length) return;
@@ -17929,6 +18105,7 @@ initSessionGroupsDnd();
17929
18105
  initSessionPicker();
17930
18106
  initProjectPicker();
17931
18107
  initShowCard();
18108
+ initAsk();
17932
18109
  for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
17933
18110
  initSidebarResize();
17934
18111
  applyModalZoom();
package/public/index.html CHANGED
@@ -362,6 +362,10 @@
362
362
  <div id="terminal-pane" class="terminal-pane">
363
363
  <div class="terminal-bar">
364
364
  <span id="terminal-status" class="terminal-status"></span>
365
+ <button id="ask-reopen" class="show-reopen ask-reopen" type="button" hidden>
366
+ <span class="ask-dot" aria-hidden="true">?</span>
367
+ <span class="ask-reopen-n"></span>
368
+ </button>
365
369
  <button id="show-reopen" class="show-reopen" type="button" title="Show the overlay" hidden>
366
370
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M13 4v7h8"/></svg>
367
371
  <span class="show-reopen-n">show</span>
@@ -375,6 +379,23 @@
375
379
  </div>
376
380
  <div id="terminal-host" class="terminal-host">
377
381
  <section id="show-card" class="show-card" aria-label="Show overlay"></section>
382
+ <div id="ask-modal" class="ask-modal" hidden>
383
+ <section class="ask-box" role="dialog" aria-modal="true" aria-labelledby="ask-title">
384
+ <header class="ask-head">
385
+ <span class="ask-dot" aria-hidden="true">?</span>
386
+ <span id="ask-title" class="ask-title"></span>
387
+ <span class="ask-hint">Esc hides</span>
388
+ <button type="button" class="ask-x" data-ask="hide" title="Hide. The question stays open." aria-label="Hide">
389
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
390
+ </button>
391
+ </header>
392
+ <div class="ask-body"></div>
393
+ <footer class="ask-foot">
394
+ <span>The agent waits for your answer.</span>
395
+ <button type="button" data-ask="skip">Skip</button>
396
+ </footer>
397
+ </section>
398
+ </div>
378
399
  </div>
379
400
  <textarea id="terminal-key-proxy" class="terminal-key-proxy" tabindex="-1" aria-hidden="true"></textarea>
380
401
  </div>
package/public/style.css CHANGED
@@ -4460,6 +4460,181 @@ body.show-resizing iframe {
4460
4460
 
4461
4461
  /* #endregion */
4462
4462
 
4463
+ /* #region ASK */
4464
+ .ask-modal {
4465
+ position: absolute;
4466
+ inset: 0;
4467
+ z-index: 5;
4468
+ display: flex;
4469
+ align-items: center;
4470
+ justify-content: center;
4471
+ background: color-mix(in srgb, var(--bg-deep) 55%, transparent);
4472
+ }
4473
+
4474
+ .ask-modal[hidden] {
4475
+ display: none;
4476
+ }
4477
+
4478
+ .ask-box {
4479
+ display: flex;
4480
+ flex-direction: column;
4481
+ box-sizing: border-box;
4482
+ width: min(560px, calc(100% - 32px));
4483
+ max-height: calc(100% - 32px);
4484
+ background: var(--bg-surface);
4485
+ border: 1px solid var(--border);
4486
+ border-radius: 10px;
4487
+ box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.45);
4488
+ overflow: hidden;
4489
+ }
4490
+
4491
+ .ask-box[data-size='lg'] {
4492
+ width: min(960px, calc(100% - 32px));
4493
+ }
4494
+
4495
+ .ask-box[data-size='full'] {
4496
+ width: calc(100% - 32px);
4497
+ height: calc(100% - 32px);
4498
+ }
4499
+
4500
+ .ask-head,
4501
+ .ask-foot {
4502
+ display: flex;
4503
+ flex: none;
4504
+ align-items: center;
4505
+ gap: 8px;
4506
+ padding: 6px 8px 6px 12px;
4507
+ color: var(--text-tertiary);
4508
+ font-size: 11px;
4509
+ }
4510
+
4511
+ .ask-head {
4512
+ border-bottom: 1px solid var(--border);
4513
+ }
4514
+
4515
+ .ask-foot {
4516
+ justify-content: space-between;
4517
+ border-top: 1px solid var(--border);
4518
+ }
4519
+
4520
+ .ask-dot {
4521
+ display: inline-grid;
4522
+ place-items: center;
4523
+ flex: none;
4524
+ width: 16px;
4525
+ height: 16px;
4526
+ border-radius: 50%;
4527
+ background: var(--accent);
4528
+ color: #fff;
4529
+ font-size: 10px;
4530
+ font-weight: 700;
4531
+ }
4532
+
4533
+ .ask-title {
4534
+ flex: 1;
4535
+ min-width: 0;
4536
+ overflow: hidden;
4537
+ color: var(--text-primary);
4538
+ font-size: 12px;
4539
+ font-weight: 600;
4540
+ text-overflow: ellipsis;
4541
+ white-space: nowrap;
4542
+ }
4543
+
4544
+ .ask-x {
4545
+ display: inline-grid;
4546
+ place-items: center;
4547
+ width: 22px;
4548
+ height: 22px;
4549
+ padding: 0;
4550
+ border: 0;
4551
+ border-radius: 4px;
4552
+ background: transparent;
4553
+ color: var(--text-tertiary);
4554
+ cursor: pointer;
4555
+ }
4556
+
4557
+ .ask-x svg {
4558
+ width: 14px;
4559
+ height: 14px;
4560
+ }
4561
+
4562
+ .ask-foot button {
4563
+ padding: 3px 12px;
4564
+ border: 1px solid var(--border);
4565
+ border-radius: 5px;
4566
+ background: transparent;
4567
+ color: var(--text-secondary);
4568
+ font: inherit;
4569
+ cursor: pointer;
4570
+ }
4571
+
4572
+ .ask-x:hover,
4573
+ .ask-foot button:hover {
4574
+ background: var(--bg-hover);
4575
+ color: var(--text-primary);
4576
+ }
4577
+
4578
+ .ask-body {
4579
+ display: flex;
4580
+ flex: 1 1 auto;
4581
+ flex-direction: column;
4582
+ min-height: 0;
4583
+ }
4584
+
4585
+ .ask-frame {
4586
+ display: block;
4587
+ width: 100%;
4588
+ border: 0;
4589
+ background: var(--bg-surface);
4590
+ }
4591
+
4592
+ .ask-box[data-size='full'] .ask-frame {
4593
+ flex: 1;
4594
+ }
4595
+
4596
+ .ask-missing {
4597
+ margin: 0;
4598
+ padding: 16px;
4599
+ color: var(--text-tertiary);
4600
+ }
4601
+
4602
+ .ask-reopen {
4603
+ max-width: 240px;
4604
+ border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
4605
+ color: var(--text-secondary);
4606
+ }
4607
+
4608
+ .ask-reopen-n {
4609
+ overflow: hidden;
4610
+ text-overflow: ellipsis;
4611
+ white-space: nowrap;
4612
+ }
4613
+
4614
+ .ask-reopen .ask-dot {
4615
+ width: 14px;
4616
+ height: 14px;
4617
+ font-size: 9px;
4618
+ animation: ask-pulse 1.6s ease-out infinite;
4619
+ }
4620
+
4621
+ @keyframes ask-pulse {
4622
+ 0% {
4623
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
4624
+ }
4625
+ 100% {
4626
+ box-shadow: 0 0 0 6px transparent;
4627
+ }
4628
+ }
4629
+
4630
+ @media (prefers-reduced-motion: reduce) {
4631
+ .ask-reopen .ask-dot {
4632
+ animation: none;
4633
+ }
4634
+ }
4635
+
4636
+ /* #endregion */
4637
+
4463
4638
  /* #region PANES */
4464
4639
  .pane-strip {
4465
4640
  display: none;
package/server.js CHANGED
@@ -51,6 +51,7 @@ const { createTerminalClient } = require('./lib/terminal-client');
51
51
  const { kanbotOn, kanbotModel, createKanbot, findCostCli, readCostServer } = require('./lib/kanbot');
52
52
  const { AGENT_ID_RE } = require('./cli');
53
53
  const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
54
+ const { createAskStore, mountAskRoutes, askBodyParser, ASK_PATH } = require('./lib/ask');
54
55
  const { readLiveSessions, isPidAlive, noteReplacedSessions } = require('./lib/live-sessions');
55
56
  const { createProcStats } = require('./lib/proc-stats');
56
57
  const { createUserGroupStore } = require('./lib/user-groups');
@@ -666,6 +667,7 @@ app.param('name', (_req, res, next, val) => {
666
667
  });
667
668
 
668
669
  app.use(SHOW_PATH, showBodyParser());
670
+ app.use(ASK_PATH, askBodyParser());
669
671
  // Parse JSON bodies. A review of 50 comments at the field caps is about 450 kB.
670
672
  app.use(express.json({ limit: '1mb' }));
671
673
  // #endregion
@@ -3326,6 +3328,7 @@ let savedTerminals = terminalsFile.load() || {};
3326
3328
  function saveTerminals() {
3327
3329
  const kept = new Set(Object.values(savedTerminals.terminalIds || {}));
3328
3330
  const showSessions = show.prune((id) => kept.has(id) || terminal.hasTerminal(id));
3331
+ ask.prune((id) => terminal.hasTerminal(id));
3329
3332
  terminalsFile.save({ ...savedTerminals, showSessions });
3330
3333
  }
3331
3334
 
@@ -3460,6 +3463,14 @@ mountShowRoutes(app, {
3460
3463
  broadcast,
3461
3464
  onRemoved: (sessionId, paths) => panes.removeShow(sessionId, paths),
3462
3465
  });
3466
+ const ask = createAskStore();
3467
+ mountAskRoutes(app, {
3468
+ store: ask,
3469
+ authorized: (token) => terminal.authorized(token),
3470
+ hasTerminal: (id) => terminal.hasTerminal(id),
3471
+ readPreviewFile,
3472
+ broadcast,
3473
+ });
3463
3474
 
3464
3475
  // Served from node_modules, never a CDN: any script on this page can use the token.
3465
3476
  const XTERM_FILES = {