feat: v0.8.2 clickable paths, alert sessions, ops guard and trail
- Terminal link provider: printed paths that are files of the session open in Files (at the line); the file list is preloaded so the first hover works. - Alerts: "In a new session" opens alert-<name> and types the prompt once Claude is up. - devdesk-guard PreToolUse(Bash) hook: permissionDecision "ask" for destructive commands (verified to prompt in auto mode); DEVDESK_GUARD and guard.local. - State hook logs Bash commands and derives host tags (ssh targets, CT/VM ids); --list carries tags, --peek the last commands; UI shows both. Closes #21 Closes #18 Closes #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+6
-1
@@ -60,5 +60,10 @@ export function renderAlerts(root, data) {
|
||||
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
|
||||
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
|
||||
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
|
||||
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
|
||||
el('div', { class: 'dd-ask-row' },
|
||||
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a), title: 'Type the prompt into the current session (not sent)' }, 'Ask Claude'),
|
||||
el('button', {
|
||||
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a),
|
||||
'data-ask-session': `alert-${a.name}`, title: 'Open a separate ops session for this alert and type the prompt there (not sent)',
|
||||
}, 'In a new session')))));
|
||||
}
|
||||
|
||||
+52
-2
@@ -437,6 +437,7 @@ export class FilesPane {
|
||||
async loadFileList() {
|
||||
if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items;
|
||||
const list = parseListOutput(await runCommand(['--fs-find', this.session], 45000));
|
||||
this.fileListRoot = list.root;
|
||||
const items = list.files.map((path) => {
|
||||
const name = base(path);
|
||||
const [icon] = iconFor(name);
|
||||
@@ -462,6 +463,9 @@ export class FilesPane {
|
||||
}
|
||||
|
||||
async claudeTurnEnded() {
|
||||
// New files may exist now: refresh the list terminal links are checked against.
|
||||
this.fileList = null;
|
||||
this.knownFiles();
|
||||
if (!this.isOpen) {
|
||||
// Next time it opens it revalidates anyway; open tabs re-check on activation.
|
||||
for (const v of this.views.values()) v.stale = true;
|
||||
@@ -707,8 +711,9 @@ export class FilesPane {
|
||||
|
||||
// Single click opens a preview tab (italic) that the next click replaces;
|
||||
// double click, Enter or go-to-file keeps it.
|
||||
openFile(path, { mode, pin = false } = {}) {
|
||||
const wanted = mode ?? (isMarkdown(path) ? 'preview' : 'view');
|
||||
openFile(path, { mode, pin = false, line } = {}) {
|
||||
// A line number means source: markdown opens as text, not as a preview.
|
||||
const wanted = mode ?? (isMarkdown(path) && !line ? 'preview' : 'view');
|
||||
const key = `${wanted}:${path}`;
|
||||
let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode);
|
||||
if (!tab) {
|
||||
@@ -729,6 +734,11 @@ export class FilesPane {
|
||||
tab.pinned = true;
|
||||
}
|
||||
this.pushRecent(path);
|
||||
if (line) {
|
||||
const loaded = this.views.get(tab.key)?.editor;
|
||||
if (loaded?.goto) setTimeout(() => loaded.goto(line), 0);
|
||||
else tab.line = line;
|
||||
}
|
||||
this.activate(tab.key);
|
||||
if (this.root.classList.contains('is-narrow')) this.setExplorer(false);
|
||||
}
|
||||
@@ -1030,9 +1040,49 @@ export class FilesPane {
|
||||
view.node.replaceChildren(this.bar(tab, header, view), content);
|
||||
if (banner) this.oldDraft(tab.key, banner);
|
||||
if (view.dirty) this.renderTabs();
|
||||
this.gotoPending(tab, view);
|
||||
return;
|
||||
}
|
||||
view.node.replaceChildren(this.bar(tab, header, view), content);
|
||||
this.gotoPending(tab, view);
|
||||
}
|
||||
|
||||
gotoPending(tab, view) {
|
||||
if (!tab.line || !view.editor?.goto) return;
|
||||
const line = tab.line;
|
||||
delete tab.line;
|
||||
setTimeout(() => view.editor.goto(line), 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ paths in the terminal (devdesk#21)
|
||||
|
||||
// The session's file list (cached by go-to-file). Loaded ahead of time (on
|
||||
// connect and after Claude's turns): xterm asks for a line's links once and
|
||||
// caches the answer, so it has to be ready before the first hover.
|
||||
knownFiles() {
|
||||
if (this.session !== this.hooks.session()) this.syncSession();
|
||||
if (this.fileList?.session === this.session) {
|
||||
if (!this.fileSet || this.fileSet.list !== this.fileList.items) {
|
||||
this.fileSet = { list: this.fileList.items, set: new Set(this.fileList.items.map((x) => x.id)) };
|
||||
}
|
||||
return this.fileSet.set;
|
||||
}
|
||||
if (!this.loadingList && this.session) {
|
||||
this.loadingList = true;
|
||||
this.loadFileList().catch(() => {}).finally(() => { this.loadingList = false; });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A path as printed in the terminal -> the session-relative path, if it's a
|
||||
// file of this session (absolute paths inside the folder count too).
|
||||
resolvePrinted(text) {
|
||||
const known = this.knownFiles();
|
||||
if (!known) return null;
|
||||
let path = text.replace(/^\.\//, '');
|
||||
const root = this.fileListRoot;
|
||||
if (path.startsWith('/') && root && path.startsWith(`${root}/`)) path = path.slice(root.length + 1);
|
||||
return known.has(path) ? path : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ editing (devdesk#20)
|
||||
|
||||
@@ -184,6 +184,10 @@ export class Inbox {
|
||||
el('div', { class: 'dd-peek-assistant' },
|
||||
el('span', { class: 'dd-peek-label' }, 'Claude'),
|
||||
data.assistant ? `${data.assistant.truncated ? '…' : ''}${data.assistant.text}` : 'Nothing yet.'),
|
||||
data.commands?.length ? el('div', { class: 'dd-peek-commands' },
|
||||
el('span', { class: 'dd-peek-label' }, 'Recent commands'),
|
||||
...data.commands.slice(-8).map((c) => el('div', { class: 'dd-peek-command', title: c.cmd },
|
||||
el('span', { class: 'dd-peek-when' }, ago(c.at)), el('code', {}, c.cmd)))) : null,
|
||||
waitingRequest
|
||||
? el('div', { class: 'dd-notif-empty' }, 'Claude is asking for a permission: answer it from the list, or open the session.')
|
||||
: el('div', { class: 'dd-peek-reply' }, input, el('div', { class: 'dd-peek-actions' },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// File paths in the terminal become links (devdesk#21): click one in Claude's
|
||||
// output (web/src/files.js, ./notes.md:12, /root/code/x/y.py:3:9) to open it in
|
||||
// the Files pane, at that line if there is one. Only paths that are files of
|
||||
// the session become links, so words and URLs are left alone.
|
||||
|
||||
// A path: optional ./ ../ or /, segments, and either a slash or an extension;
|
||||
// then an optional :line[:col].
|
||||
const PATH_RE = /(?<![\w/.@~+-])((?:\.{1,2}\/|\/)?(?:[\w@+~-][\w.@+~-]*\/)*[\w@+~-][\w.@+~-]*(?:\.[A-Za-z0-9]{1,10})?)(?::(\d+)(?::(\d+))?)?/g;
|
||||
const URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
|
||||
|
||||
export function findPaths(text) {
|
||||
const urls = [...text.matchAll(URL_RE)].map((m) => [m.index, m.index + m[0].length]);
|
||||
const out = [];
|
||||
for (const m of text.matchAll(PATH_RE)) {
|
||||
const candidate = m[1];
|
||||
if (!candidate.includes('/') && !/\.[A-Za-z0-9]{1,10}$/.test(candidate)) continue;
|
||||
if (urls.some(([a, b]) => m.index >= a && m.index < b)) continue;
|
||||
out.push({ index: m.index, length: m[0].length, path: candidate, line: m[2] ? Number(m[2]) : undefined });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerPathLinks(term, { resolve, open }) {
|
||||
term.registerLinkProvider({
|
||||
provideLinks(y, callback) {
|
||||
const line = term.buffer.active.getLine(y - 1);
|
||||
if (!line) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const links = [];
|
||||
for (const found of findPaths(line.translateToString(true))) {
|
||||
const path = resolve(found.path);
|
||||
if (!path) continue;
|
||||
links.push({
|
||||
range: { start: { x: found.index + 1, y }, end: { x: found.index + found.length, y } },
|
||||
text: path,
|
||||
decorations: { underline: true, pointerCursor: true },
|
||||
activate: () => open(path, found.line),
|
||||
});
|
||||
}
|
||||
callback(links.length ? links : undefined);
|
||||
},
|
||||
});
|
||||
}
|
||||
+52
-4
@@ -4,7 +4,7 @@ import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
||||
import {
|
||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
||||
} from './connection.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
||||
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
|
||||
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||
import { renderAlerts } from './alerts.js';
|
||||
import { renderRepo } from './repo.js';
|
||||
@@ -13,6 +13,7 @@ import { heardWithin, onShared, share } from './shared.js';
|
||||
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
||||
import { Inbox } from './inbox.js';
|
||||
import { FilesPane } from './files.js';
|
||||
import { registerPathLinks } from './links.js';
|
||||
import { el } from './dom.js';
|
||||
import * as notify from './notify.js';
|
||||
|
||||
@@ -357,6 +358,7 @@ function connect(session, { existing = false, reconnect = false } = {}) {
|
||||
state.attempts = 0;
|
||||
setWsState('connected', 'live');
|
||||
if (session) rememberSession(session);
|
||||
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
|
||||
refreshSoon(800); // opening ?arg=<new name> just created this session
|
||||
refreshRepo();
|
||||
fit.fit();
|
||||
@@ -548,7 +550,8 @@ function renderSwitcher() {
|
||||
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
|
||||
},
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-switch-name' }, s.name),
|
||||
el('span', { class: 'dd-switch-name' }, s.name,
|
||||
s.tags?.length ? el('span', { class: 'dd-tags' }, ...s.tags.slice(0, 2).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
|
||||
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
|
||||
});
|
||||
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
|
||||
@@ -589,6 +592,38 @@ async function toggleNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
// "In a new session" (devdesk#18): open (or reuse) a session for this alert,
|
||||
// wait until its Claude is up (SessionStart sets it idle), then type the
|
||||
// prompt without sending it.
|
||||
let pendingAsk = null;
|
||||
function askInSession(name, text) {
|
||||
closeDrawerIfNarrow();
|
||||
pendingAsk = { session: name, text, until: Date.now() + 60000 };
|
||||
connect(name);
|
||||
toast.info(`Opening ${name}; the prompt will be typed when Claude is ready`);
|
||||
const tick = async () => {
|
||||
if (!pendingAsk || pendingAsk.session !== name) return;
|
||||
if (Date.now() > pendingAsk.until) {
|
||||
toast.warning(`${name}: Claude didn't start in time; the prompt was not typed`);
|
||||
pendingAsk = null;
|
||||
return;
|
||||
}
|
||||
await sessionsPanel.reload();
|
||||
const s = sessionsPanel.data?.sessions?.find((x) => x.name === name);
|
||||
const ready = s && ['idle', 'waiting', 'working'].includes(sessionStatus(s).key);
|
||||
if (ready && state.session === name && state.conn?.isOpen) {
|
||||
setTimeout(() => {
|
||||
term.paste(pasteSafe(pendingAsk.text));
|
||||
term.focus();
|
||||
pendingAsk = null;
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 1500);
|
||||
};
|
||||
setTimeout(tick, 1500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- palette & help
|
||||
|
||||
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
||||
@@ -620,7 +655,8 @@ function buildCommands() {
|
||||
if (s.name === state.session) continue;
|
||||
const status = sessionStatus(s);
|
||||
add({
|
||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
|
||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: [status.label, ...(s.tags ?? []).slice(0, 2)].join(' · '),
|
||||
keywords: `${s.dir} ${(s.tags ?? []).join(' ')}`,
|
||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
|
||||
});
|
||||
}
|
||||
@@ -718,6 +754,7 @@ const KEY_HELP = [
|
||||
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
||||
['⇧ drag', 'Select text in the browser'],
|
||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
||||
['Click a file path', 'Open it in Files (at the line, if given)'],
|
||||
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
|
||||
]],
|
||||
];
|
||||
@@ -867,6 +904,13 @@ function wireChrome() {
|
||||
},
|
||||
});
|
||||
$('files-toggle').addEventListener('click', () => files.toggle());
|
||||
registerPathLinks(term, {
|
||||
resolve: (printed) => (state.session ? files.resolvePrinted(printed) : null),
|
||||
open: (path, line) => {
|
||||
if (!files.isOpen) files.open();
|
||||
files.openFile(path, { pin: true, line });
|
||||
},
|
||||
});
|
||||
wireResizer();
|
||||
palette = new CommandPalette(buildCommands);
|
||||
$('cmd-toggle').addEventListener('click', () => palette.open());
|
||||
@@ -1079,7 +1123,11 @@ async function boot() {
|
||||
onData: (_data, meta) => onSessionData(meta),
|
||||
},
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text) => {
|
||||
bindAskClaude($('sidebar'), (text, session) => {
|
||||
if (session) {
|
||||
askInSession(slugify(session).slice(0, 48) || 'alert', text);
|
||||
return;
|
||||
}
|
||||
// Alert text is untrusted: no control characters (a newline would run it in a
|
||||
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
||||
term.paste(pasteSafe(text));
|
||||
|
||||
+2
-1
@@ -285,7 +285,8 @@ export class SessionsPanel {
|
||||
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
|
||||
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
||||
el('span', { class: `lt-dot ${status.dot}` }),
|
||||
el('span', { class: 'dd-session-name' }, s.name),
|
||||
el('span', { class: 'dd-session-name' }, s.name,
|
||||
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
|
||||
el('span', { class: 'dd-session-meta' }, meta),
|
||||
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
|
||||
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
|
||||
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
|
||||
// prompt into the current session without pressing Enter, so you can review it
|
||||
// before sending.
|
||||
// prompt into a session without pressing Enter, so you can review it before
|
||||
// sending: the current session, or (data-ask-session) a separate one, e.g. an
|
||||
// ops session per alert (devdesk#18).
|
||||
|
||||
export function bindAskClaude(root, send) {
|
||||
root.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-ask-claude]');
|
||||
if (!button || button.disabled) return;
|
||||
send(button.dataset.askClaude);
|
||||
send(button.dataset.askClaude, button.dataset.askSession || null);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user