sync: update from private repo (6c4d482)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
caa0d03900
commit
03be80f036
@ -127,6 +127,46 @@ Return (kind=scrollback):
|
|||||||
|
|
||||||
text は ANSI escape strip 済み (色 / cursor 移動シーケンスを除去)。raw が必要な場合は audit log を参照。
|
text は ANSI escape strip 済み (色 / cursor 移動シーケンスを除去)。raw が必要な場合は audit log を参照。
|
||||||
|
|
||||||
|
## SshConsoleRun
|
||||||
|
|
||||||
|
**通常のコマンドは SshConsoleRun を使う。** raw SshConsoleSend は対話操作 (vim/REPL/sudo/TUI) と本当の中断専用。長いコマンドを Ctrl-C で殺さないこと。Ctrl-C は confirm_interrupt:true が必要。
|
||||||
|
|
||||||
|
シェルコマンドを実行し、**完了まで blocking して** `{done, exit_code, output}` を返す。`SshConsoleSend` のように「画面取得のタイミングを気にしてから結果を読む」作業が不要で、終了コードも自動取得できる。
|
||||||
|
|
||||||
|
| Param | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `command` | yes | 実行するシェルコマンド |
|
||||||
|
| `connection_id` | no | UUID。**省略時はこの task の active session を自動採用 (推奨)** |
|
||||||
|
| `timeout_ms` | no | タイムアウト (ms)。デフォルト 120000 (2分)、最大 600000 (10分)。タイムアウト時もコマンドは kill されない |
|
||||||
|
| `idle_ms` | no | 出力が `idle_ms` ms 途切れたら早期終了と判定する。0=無効 (デフォルト) |
|
||||||
|
|
||||||
|
Return:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"done": true,
|
||||||
|
"exit_code": 0,
|
||||||
|
"output": "... コマンドの出力 ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`done: false` はタイムアウトで終了したケース。`exit_code` は shell が返した終了コード (非 0 はエラー)。
|
||||||
|
|
||||||
|
### SshConsoleRun vs SshConsoleSend の使い分け
|
||||||
|
|
||||||
|
| 用途 | 使うツール |
|
||||||
|
|---|---|
|
||||||
|
| 通常のシェルコマンド (ls, grep, systemctl, make, ...) | **SshConsoleRun** |
|
||||||
|
| 対話的 TUI (vim, top, htop, tmux, ...) | SshConsoleSend + SshConsoleSnapshot |
|
||||||
|
| REPL / sudo パスワード入力 | SshConsoleSend |
|
||||||
|
| プロセス中断 (Ctrl-C) | SshConsoleSend({input: "\\x03", confirm_interrupt: true}) |
|
||||||
|
| 長時間バックグラウンドを待つ | SshConsoleRun({timeout_ms: 300000}) |
|
||||||
|
|
||||||
|
### よくある間違い
|
||||||
|
|
||||||
|
- 長時間コマンドに `timeout_ms` を指定し忘れる → デフォルト 2 分でタイムアウト。`timeout_ms` を伸ばすこと
|
||||||
|
- コマンドが止まらないからと安易に Ctrl-C を送る → `confirm_interrupt:true` を付けた SshConsoleSend が必要。**SshConsoleRun の途中で別の SshConsoleSend を送ってはいけない**
|
||||||
|
- `done: false` を無視してそのまま次に進む → コマンドはまだ動いている可能性がある。SshConsoleSnapshot で状態確認
|
||||||
|
|
||||||
## エラー時のリカバリ
|
## エラー時のリカバリ
|
||||||
|
|
||||||
| エラー | 対応 |
|
| エラー | 対応 |
|
||||||
|
|||||||
@ -70,7 +70,7 @@ movements:
|
|||||||
- 完了: complete({status: "success", result: "..."})
|
- 完了: complete({status: "success", result: "..."})
|
||||||
- 中断: complete({status: "aborted", abort_reason: "..."})
|
- 中断: complete({status: "aborted", abort_reason: "..."})
|
||||||
- 確認待ち: complete({status: "needs_user_input", missing_info: "..."})
|
- 確認待ち: complete({status: "needs_user_input", missing_info: "..."})
|
||||||
allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, Read, Write, Bash, Glob, Grep]
|
allowed_tools: [SshConsoleEnsure, SshConsoleSend, SshConsoleRun, SshConsoleSnapshot, SshUpload, SshDownload, SshListConnections, Read, Write, Bash, Glob, Grep]
|
||||||
allowed_ssh_connections: ['*']
|
allowed_ssh_connections: ['*']
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules: []
|
rules: []
|
||||||
|
|||||||
@ -193,7 +193,8 @@ function appendConsoleScreenIfAny(
|
|||||||
if (!_activeSessionLookup || taskId === undefined || taskId === null) return prompt;
|
if (!_activeSessionLookup || taskId === undefined || taskId === null) return prompt;
|
||||||
const allowsConsole =
|
const allowsConsole =
|
||||||
movement.allowedTools.includes('SshConsoleSend') ||
|
movement.allowedTools.includes('SshConsoleSend') ||
|
||||||
movement.allowedTools.includes('SshConsoleSnapshot');
|
movement.allowedTools.includes('SshConsoleSnapshot') ||
|
||||||
|
movement.allowedTools.includes('SshConsoleRun');
|
||||||
if (!allowsConsole) return prompt;
|
if (!allowsConsole) return prompt;
|
||||||
const session = _activeSessionLookup(String(taskId));
|
const session = _activeSessionLookup(String(taskId));
|
||||||
if (!session) return prompt;
|
if (!session) return prompt;
|
||||||
|
|||||||
@ -149,7 +149,7 @@ export function validatePieceDef(piece: PieceDef): void {
|
|||||||
*/
|
*/
|
||||||
const SSH_TOOL_NAMES: ReadonlySet<string> = new Set([
|
const SSH_TOOL_NAMES: ReadonlySet<string> = new Set([
|
||||||
'SshExec', 'SshUpload', 'SshDownload', 'SshListConnections',
|
'SshExec', 'SshUpload', 'SshDownload', 'SshListConnections',
|
||||||
'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot',
|
'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', 'SshConsoleRun',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
43
src/engine/tools/console-run-lib.test.ts
Normal file
43
src/engine/tools/console-run-lib.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js';
|
||||||
|
|
||||||
|
describe('console-run-lib', () => {
|
||||||
|
it('makeNonce is long, hex, and unique', () => {
|
||||||
|
const a = makeNonce(), b = makeNonce();
|
||||||
|
expect(a).toMatch(/^[0-9a-f]{24,}$/);
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
it('makeMarkerCommand wraps the command with a nonce echo of $?', () => {
|
||||||
|
const { text, marker } = makeMarkerCommand('ls -la', 'deadbeef');
|
||||||
|
expect(text).toBe('ls -la; echo "__MAESTRO_DONE_deadbeef:$?__"\n');
|
||||||
|
expect(marker).toBe('__MAESTRO_DONE_deadbeef:');
|
||||||
|
});
|
||||||
|
it('parseMarker finds the exit code from output text', () => {
|
||||||
|
expect(parseMarker('foo\n__MAESTRO_DONE_deadbeef:0__\n', 'deadbeef')).toEqual({ found: true, exitCode: 0 });
|
||||||
|
expect(parseMarker('foo\n__MAESTRO_DONE_deadbeef:130__\n', 'deadbeef')).toEqual({ found: true, exitCode: 130 });
|
||||||
|
expect(parseMarker('still running...', 'deadbeef')).toEqual({ found: false, exitCode: null });
|
||||||
|
// a different nonce in the stream must NOT match (anti-spoof)
|
||||||
|
expect(parseMarker('__MAESTRO_DONE_otheronce:0__', 'deadbeef')).toEqual({ found: false, exitCode: null });
|
||||||
|
});
|
||||||
|
it('extractOutput strips the echoed command line and the marker line', () => {
|
||||||
|
const raw = 'ls -la; echo "__MAESTRO_DONE_dead:$?__"\nfile1\nfile2\n__MAESTRO_DONE_dead:0__\n';
|
||||||
|
const out = extractOutput(raw, 'ls -la', 'dead');
|
||||||
|
expect(out).toContain('file1');
|
||||||
|
expect(out).toContain('file2');
|
||||||
|
expect(out).not.toContain('__MAESTRO_DONE_');
|
||||||
|
});
|
||||||
|
it('detectWaitingForInput spots common prompts in the screen tail', () => {
|
||||||
|
expect(detectWaitingForInput('Password: ')).toBe(true);
|
||||||
|
expect(detectWaitingForInput('Continue? [y/N] ')).toBe(true);
|
||||||
|
expect(detectWaitingForInput('Are you sure (yes/no)? ')).toBe(true);
|
||||||
|
expect(detectWaitingForInput('just some output')).toBe(false);
|
||||||
|
});
|
||||||
|
it('shouldGuardInterrupt: guards when alive, allows when idle or confirmed', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
const alive = { idleMs: 200, msSinceAiInput: 300, sessionAgeMs: 1000 };
|
||||||
|
const idle = { idleMs: 60_000, msSinceAiInput: 60_000, sessionAgeMs: 60_000 };
|
||||||
|
expect(shouldGuardInterrupt(alive, false)).toBe(true); // block
|
||||||
|
expect(shouldGuardInterrupt(alive, true)).toBe(false); // confirmed -> allow
|
||||||
|
expect(shouldGuardInterrupt(idle, false)).toBe(false); // idle -> allow
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/engine/tools/console-run-lib.ts
Normal file
45
src/engine/tools/console-run-lib.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const MARKER_PREFIX = '__MAESTRO_DONE_';
|
||||||
|
|
||||||
|
export function makeNonce(): string {
|
||||||
|
return randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeMarkerCommand(command: string, nonce: string): { text: string; marker: string } {
|
||||||
|
const marker = `${MARKER_PREFIX}${nonce}:`;
|
||||||
|
// `$?` after `command;` reflects the command's exit code.
|
||||||
|
return { text: `${command}; echo "${marker}$?__"\n`, marker };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMarker(text: string, nonce: string): { found: boolean; exitCode: number | null } {
|
||||||
|
const re = new RegExp(`${MARKER_PREFIX}${nonce}:(\\d+)__`);
|
||||||
|
const m = re.exec(text);
|
||||||
|
if (!m) return { found: false, exitCode: null };
|
||||||
|
return { found: true, exitCode: Number(m[1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: drop the echoed command line and the marker line(s). PTY echo /
|
||||||
|
// prompt noise may remain — perfect formatting is a non-goal.
|
||||||
|
export function extractOutput(raw: string, command: string, nonce: string): string {
|
||||||
|
const markerRe = new RegExp(`${MARKER_PREFIX}${nonce}:.*?__`);
|
||||||
|
return raw
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !markerRe.test(line))
|
||||||
|
.filter((line) => line.trim() !== `${command}; echo "${MARKER_PREFIX}${nonce}:$?__"`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const WAITING_RE = /(password:|\[y\/n\]|\(yes\/no\)\??|continue\?|press .*key|passphrase:)\s*$/i;
|
||||||
|
export function detectWaitingForInput(screenTail: string): boolean {
|
||||||
|
const lastLines = screenTail.split('\n').slice(-3).join('\n').trim();
|
||||||
|
return WAITING_RE.test(lastLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterruptLiveness { idleMs: number; msSinceAiInput: number; sessionAgeMs: number; }
|
||||||
|
const GUARD_IDLE_MS = 3000, GUARD_RECENT_INPUT_MS = 5000, GUARD_SESSION_AGE_MS = 5000;
|
||||||
|
export function shouldGuardInterrupt(l: InterruptLiveness, confirmInterrupt: boolean): boolean {
|
||||||
|
if (confirmInterrupt) return false;
|
||||||
|
const alive = l.idleMs < GUARD_IDLE_MS || l.msSinceAiInput < GUARD_RECENT_INPUT_MS || l.sessionAgeMs < GUARD_SESSION_AGE_MS;
|
||||||
|
return alive; // true => block the interrupt
|
||||||
|
}
|
||||||
@ -75,6 +75,7 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
|||||||
sshconsoleensure: 'ssh-console-tools',
|
sshconsoleensure: 'ssh-console-tools',
|
||||||
sshconsolesend: 'ssh-console-tools',
|
sshconsolesend: 'ssh-console-tools',
|
||||||
sshconsolesnapshot: 'ssh-console-tools',
|
sshconsolesnapshot: 'ssh-console-tools',
|
||||||
|
sshconsolerun: 'ssh-console-tools',
|
||||||
// slide.ts をまとめる
|
// slide.ts をまとめる
|
||||||
settheme: 'slide',
|
settheme: 'slide',
|
||||||
addslide: 'slide',
|
addslide: 'slide',
|
||||||
|
|||||||
@ -273,12 +273,16 @@ describe('SshConsoleSend', () => {
|
|||||||
it('writes input to session and returns screen snapshot', async () => {
|
it('writes input to session and returns screen snapshot', async () => {
|
||||||
const { sub, registry } = mkStubSubsystem();
|
const { sub, registry } = mkStubSubsystem();
|
||||||
const writes: Buffer[] = [];
|
const writes: Buffer[] = [];
|
||||||
|
const now = Date.now();
|
||||||
const fakeSession = {
|
const fakeSession = {
|
||||||
localTaskId: 'task-1',
|
localTaskId: 'task-1',
|
||||||
connectionId: 'conn-1',
|
connectionId: 'conn-1',
|
||||||
cols: 80,
|
cols: 80,
|
||||||
rows: 24,
|
rows: 24,
|
||||||
isClosed: false,
|
isClosed: false,
|
||||||
|
idleMs: () => 10_000,
|
||||||
|
lastAiInputAt: now - 10_000,
|
||||||
|
startedAt: now - 20_000,
|
||||||
write: (b: Buffer) => writes.push(b),
|
write: (b: Buffer) => writes.push(b),
|
||||||
snapshotScreen: () => ({
|
snapshotScreen: () => ({
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@ -306,9 +310,13 @@ describe('SshConsoleSend', () => {
|
|||||||
it('auto-appends \\n when input is printable without line terminator', async () => {
|
it('auto-appends \\n when input is printable without line terminator', async () => {
|
||||||
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
||||||
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
||||||
|
const now = Date.now();
|
||||||
const fakeSession = {
|
const fakeSession = {
|
||||||
localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24,
|
localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24,
|
||||||
isClosed: false,
|
isClosed: false,
|
||||||
|
idleMs: () => 10_000,
|
||||||
|
lastAiInputAt: now - 10_000,
|
||||||
|
startedAt: now - 20_000,
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ', cursor: { x: 0, y: 0 } }),
|
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ', cursor: { x: 0, y: 0 } }),
|
||||||
totalOutputBytes: 0,
|
totalOutputBytes: 0,
|
||||||
@ -334,9 +342,15 @@ describe('SshConsoleSend', () => {
|
|||||||
it('does NOT auto-append newline for control bytes (Ctrl-C / Ctrl-D / Esc / Tab)', async () => {
|
it('does NOT auto-append newline for control bytes (Ctrl-C / Ctrl-D / Esc / Tab)', async () => {
|
||||||
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
||||||
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
||||||
|
const now = Date.now();
|
||||||
const fakeSession = {
|
const fakeSession = {
|
||||||
localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24,
|
localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24,
|
||||||
isClosed: false, write: vi.fn(),
|
isClosed: false,
|
||||||
|
// Large idleMs so \x03 passes the guard (session appears idle, no block)
|
||||||
|
idleMs: () => 10_000,
|
||||||
|
lastAiInputAt: now - 10_000,
|
||||||
|
startedAt: now - 20_000,
|
||||||
|
write: vi.fn(),
|
||||||
snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
|
snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
|
||||||
totalOutputBytes: 0,
|
totalOutputBytes: 0,
|
||||||
};
|
};
|
||||||
@ -411,11 +425,15 @@ describe('SshConsoleSend', () => {
|
|||||||
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
const { sub, registry, connectionRepo } = mkStubSubsystem();
|
||||||
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
connectionRepo.resolveConnection.mockReturnValue(mkConn());
|
||||||
const writes: Buffer[] = [];
|
const writes: Buffer[] = [];
|
||||||
|
const now = Date.now();
|
||||||
registry.get.mockReturnValue({
|
registry.get.mockReturnValue({
|
||||||
localTaskId: 'task-1',
|
localTaskId: 'task-1',
|
||||||
connectionId: 'conn-1',
|
connectionId: 'conn-1',
|
||||||
cols: 80, rows: 24,
|
cols: 80, rows: 24,
|
||||||
isClosed: false,
|
isClosed: false,
|
||||||
|
idleMs: () => 10_000,
|
||||||
|
lastAiInputAt: now - 10_000,
|
||||||
|
startedAt: now - 20_000,
|
||||||
write: (b: Buffer) => writes.push(b),
|
write: (b: Buffer) => writes.push(b),
|
||||||
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'ok', cursor: { x: 0, y: 0 } }),
|
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'ok', cursor: { x: 0, y: 0 } }),
|
||||||
totalOutputBytes: 0,
|
totalOutputBytes: 0,
|
||||||
@ -456,6 +474,136 @@ describe('SshConsoleSend', () => {
|
|||||||
expect(res?.isError).toBe(true);
|
expect(res?.isError).toBe(true);
|
||||||
expect(res?.output).toContain('SshListConnections');
|
expect(res?.output).toContain('SshListConnections');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Task 4: interrupt guard tests ──────────────────────────────────
|
||||||
|
|
||||||
|
/** Build a fake session for Send that includes liveness getters needed by Task 4. */
|
||||||
|
function mkSendSession(overrides: {
|
||||||
|
idleMs?: number;
|
||||||
|
lastAiInputAt?: number;
|
||||||
|
startedAt?: number;
|
||||||
|
screenText?: string;
|
||||||
|
} = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
localTaskId: 'task-1',
|
||||||
|
connectionId: 'conn-1',
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
isClosed: false,
|
||||||
|
totalOutputBytes: 0,
|
||||||
|
idleMs: vi.fn().mockReturnValue(overrides.idleMs ?? 500),
|
||||||
|
lastAiInputAt: overrides.lastAiInputAt ?? now - 1000,
|
||||||
|
startedAt: overrides.startedAt ?? now - 2000,
|
||||||
|
write: vi.fn(),
|
||||||
|
snapshotScreen: () => ({
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
text: overrides.screenText ?? 'prompt$ ',
|
||||||
|
cursor: { x: 0, y: 0 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('blocks \\x03 when session is recently active and confirm_interrupt is absent', async () => {
|
||||||
|
const { sub, registry } = mkStubSubsystem();
|
||||||
|
const now = Date.now();
|
||||||
|
// Small idleMs (recent output) → guard fires
|
||||||
|
const fakeSession = mkSendSession({
|
||||||
|
idleMs: 500, // < GUARD_IDLE_MS (3000) → session alive
|
||||||
|
lastAiInputAt: now - 1000, // < GUARD_RECENT_INPUT_MS (5000)
|
||||||
|
startedAt: now - 2000, // < GUARD_SESSION_AGE_MS (5000)
|
||||||
|
});
|
||||||
|
registry.get.mockReturnValue(fakeSession);
|
||||||
|
setSshSubsystem(sub);
|
||||||
|
|
||||||
|
const res = await executeTool(
|
||||||
|
'SshConsoleSend',
|
||||||
|
{ connection_id: 'conn-1', input: '\x03', wait_ms: 0 },
|
||||||
|
mkCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res?.isError).toBe(false);
|
||||||
|
const data = JSON.parse(res!.output);
|
||||||
|
expect(data.ok).toBe(false);
|
||||||
|
expect(data.interrupt_blocked).toBe(true);
|
||||||
|
expect(data.require_confirm).toBe(true);
|
||||||
|
expect(data.reason).toMatch(/running/i);
|
||||||
|
expect(data.idle_ms).toBeDefined();
|
||||||
|
expect(data.hint).toContain('confirm_interrupt');
|
||||||
|
// session.write must NOT have been called
|
||||||
|
expect(fakeSession.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows \\x03 when confirm_interrupt=true even if session is recently active', async () => {
|
||||||
|
const { sub, registry } = mkStubSubsystem();
|
||||||
|
const now = Date.now();
|
||||||
|
const fakeSession = mkSendSession({
|
||||||
|
idleMs: 500,
|
||||||
|
lastAiInputAt: now - 1000,
|
||||||
|
startedAt: now - 2000,
|
||||||
|
});
|
||||||
|
registry.get.mockReturnValue(fakeSession);
|
||||||
|
setSshSubsystem(sub);
|
||||||
|
|
||||||
|
const res = await executeTool(
|
||||||
|
'SshConsoleSend',
|
||||||
|
{ connection_id: 'conn-1', input: '\x03', wait_ms: 0, confirm_interrupt: true },
|
||||||
|
mkCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res?.isError).toBe(false);
|
||||||
|
const data = JSON.parse(res!.output);
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.interrupt_blocked).toBeUndefined();
|
||||||
|
// session.write MUST have been called with the \x03 byte
|
||||||
|
expect(fakeSession.write).toHaveBeenCalledTimes(1);
|
||||||
|
const writtenBuf = fakeSession.write.mock.calls[0][0] as Buffer;
|
||||||
|
expect(writtenBuf.includes(0x03)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows \\x03 without confirm when session is clearly idle (large idleMs + old timestamps)', async () => {
|
||||||
|
const { sub, registry } = mkStubSubsystem();
|
||||||
|
const now = Date.now();
|
||||||
|
const fakeSession = mkSendSession({
|
||||||
|
idleMs: 10_000, // > GUARD_IDLE_MS (3000)
|
||||||
|
lastAiInputAt: now - 9_000, // > GUARD_RECENT_INPUT_MS (5000)
|
||||||
|
startedAt: now - 20_000, // > GUARD_SESSION_AGE_MS (5000)
|
||||||
|
});
|
||||||
|
registry.get.mockReturnValue(fakeSession);
|
||||||
|
setSshSubsystem(sub);
|
||||||
|
|
||||||
|
const res = await executeTool(
|
||||||
|
'SshConsoleSend',
|
||||||
|
{ connection_id: 'conn-1', input: '\x03', wait_ms: 0 },
|
||||||
|
mkCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res?.isError).toBe(false);
|
||||||
|
const data = JSON.parse(res!.output);
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.interrupt_blocked).toBeUndefined();
|
||||||
|
expect(fakeSession.write).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normal printable Send result includes idle_ms field', async () => {
|
||||||
|
const { sub, registry } = mkStubSubsystem();
|
||||||
|
const fakeSession = mkSendSession({ idleMs: 42 });
|
||||||
|
registry.get.mockReturnValue(fakeSession);
|
||||||
|
setSshSubsystem(sub);
|
||||||
|
|
||||||
|
const res = await executeTool(
|
||||||
|
'SshConsoleSend',
|
||||||
|
{ connection_id: 'conn-1', input: 'ls\n', wait_ms: 0 },
|
||||||
|
mkCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res?.isError).toBe(false);
|
||||||
|
const data = JSON.parse(res!.output);
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(typeof data.idle_ms).toBe('number');
|
||||||
|
expect(data.maybe_waiting_for_input).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SshConsoleSnapshot', () => {
|
describe('SshConsoleSnapshot', () => {
|
||||||
@ -554,3 +702,146 @@ describe('SshConsoleSnapshot', () => {
|
|||||||
expect(res?.output).toContain('conn-ACTIVE');
|
expect(res?.output).toContain('conn-ACTIVE');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SshConsoleRun', () => {
|
||||||
|
beforeEach(() => setSshSubsystem(null));
|
||||||
|
|
||||||
|
/** Build a fake session that supports onOutput, write (spy), scrollbackBytes, snapshotScreen,
|
||||||
|
* lastHumanInputAt, lastAiInputAt, idleMs, startedAt — matching the ConsoleSession public surface. */
|
||||||
|
function mkFakeSession() {
|
||||||
|
const outputListeners: Set<(chunk: Buffer) => void> = new Set();
|
||||||
|
let _scrollback = Buffer.alloc(0);
|
||||||
|
let _lastHumanInputAt = 0;
|
||||||
|
let _lastAiInputAt = 0;
|
||||||
|
let _startedAt = Date.now();
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
localTaskId: 'task-1',
|
||||||
|
connectionId: 'conn-1',
|
||||||
|
cols: 80,
|
||||||
|
rows: 24,
|
||||||
|
isClosed: false,
|
||||||
|
startedAt: _startedAt,
|
||||||
|
get lastHumanInputAt() { return _lastHumanInputAt; },
|
||||||
|
get lastAiInputAt() { return _lastAiInputAt; },
|
||||||
|
idleMs: vi.fn().mockReturnValue(50),
|
||||||
|
totalOutputBytes: 0,
|
||||||
|
/** Spy-able write: when source='human', bump humanInputAt so the run loop detects it. */
|
||||||
|
write: vi.fn((buf: Buffer, source: 'ai' | 'human') => {
|
||||||
|
if (source === 'human') _lastHumanInputAt = Date.now();
|
||||||
|
else _lastAiInputAt = Date.now();
|
||||||
|
_scrollback = Buffer.concat([_scrollback, buf]);
|
||||||
|
}),
|
||||||
|
onOutput: (listener: (chunk: Buffer) => void) => {
|
||||||
|
outputListeners.add(listener);
|
||||||
|
return () => { outputListeners.delete(listener); };
|
||||||
|
},
|
||||||
|
scrollbackBytes: () => _scrollback,
|
||||||
|
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ', cursor: { x: 0, y: 0 } }),
|
||||||
|
/** Test helper: push a chunk to all output listeners + append to scrollback. */
|
||||||
|
_pushOutput(chunk: Buffer) {
|
||||||
|
_scrollback = Buffer.concat([_scrollback, chunk]);
|
||||||
|
for (const l of outputListeners) l(chunk);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('SshConsoleRun is registered in TOOL_DEFS', () => {
|
||||||
|
expect(TOOL_DEFS.SshConsoleRun).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves done+exit_code=0 when marker is pushed to session output', async () => {
|
||||||
|
const { sub, registry } = mkStubSubsystem();
|
||||||
|
const fakeSession = mkFakeSession();
|
||||||
|
registry.get.mockReturnValue(fakeSession);
|
||||||
|
setSshSubsystem(sub);
|
||||||
|
|
||||||
|
// Start the run — it will block waiting for output.
|
||||||
|
const runPromise = executeTool(
|
||||||
|
'SshConsoleRun',
|
||||||
|
{ connection_id: 'conn-1', command: 'echo hello', timeout_ms: 5000 },
|
||||||
|
mkCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Let the run() function start and subscribe to onOutput.
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
// Extract the nonce from the command text written to session.write (the markerCommand).
|
||||||
|
// write spy: first call is the markerCommand text (after deny-check etc.)
|
||||||
|
// Find the write call for source='ai' that contains the marker.
|
||||||
|
let nonce: string | undefined;
|
||||||
|
for (const call of fakeSession.write.mock.calls) {
|
||||||
|
const text = (call[0] as Buffer).toString('utf8');
|
||||||
|
const m = /__MAESTRO_DONE_([0-9a-f]+):/.exec(text);
|
||||||
|
if (m) { nonce = m[1]; break; }
|
||||||
|
}
|
||||||
|
expect(nonce).toBeDefined();
|
||||||
|
|
||||||
|
// Push some output then the marker line.
|
||||||
|
fakeSession._pushOutput(Buffer.from('hello\n'));
|
||||||
|
fakeSession._pushOutput(Buffer.from(`__MAESTRO_DONE_${nonce!}:0__\n`));
|
||||||
|
|
||||||
|
const res = await runPromise;
|
||||||
|
expect(res?.isError).toBe(false);
|
||||||
|
const data = JSON.parse(res!.output);
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.done).toBe(true);
|
||||||
|
expect(data.exit_code).toBe(0);
|
||||||
|
expect(data.marker_found).toBe(true);
|
||||||
|
expect(data.output).toContain('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns timed_out=true on timeout and does NOT write Ctrl-C (\\x03)', async () => {
|
||||||
|
const { sub, registry } = mkStubSubsystem();
|
||||||
|
const fakeSession = mkFakeSession();
|
||||||
|
registry.get.mockReturnValue(fakeSession);
|
||||||
|
setSshSubsystem(sub);
|
||||||
|
|
||||||
|
const res = await executeTool(
|
||||||
|
'SshConsoleRun',
|
||||||
|
{ connection_id: 'conn-1', command: 'sleep 99', timeout_ms: 50 },
|
||||||
|
mkCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res?.isError).toBe(false);
|
||||||
|
const data = JSON.parse(res!.output);
|
||||||
|
expect(data.done).toBe(false);
|
||||||
|
expect(data.timed_out).toBe(true);
|
||||||
|
|
||||||
|
// Ctrl-C must NEVER be sent on timeout.
|
||||||
|
const allWritten = fakeSession.write.mock.calls
|
||||||
|
.map((c) => (c[0] as Buffer).toString('binary'))
|
||||||
|
.join('');
|
||||||
|
expect(allWritten).not.toContain('\x03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns interrupted_by=human when human writes during the wait', async () => {
|
||||||
|
const { sub, registry } = mkStubSubsystem();
|
||||||
|
const fakeSession = mkFakeSession();
|
||||||
|
registry.get.mockReturnValue(fakeSession);
|
||||||
|
setSshSubsystem(sub);
|
||||||
|
|
||||||
|
const runPromise = executeTool(
|
||||||
|
'SshConsoleRun',
|
||||||
|
{ connection_id: 'conn-1', command: 'sleep 10', timeout_ms: 5000 },
|
||||||
|
mkCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Let the run loop subscribe and start the interval.
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
// Simulate a human keystroke — this updates lastHumanInputAt on the fake session.
|
||||||
|
fakeSession.write(Buffer.from('q'), 'human');
|
||||||
|
|
||||||
|
// Wait for the interval (100ms) to fire and detect the human input.
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
const res = await runPromise;
|
||||||
|
expect(res?.isError).toBe(false);
|
||||||
|
const data = JSON.parse(res!.output);
|
||||||
|
expect(data.done).toBe(false);
|
||||||
|
expect(data.interrupted_by).toBe('human');
|
||||||
|
expect(data.timed_out).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import { checkConsoleInput } from '../../ssh/console-deny-check.js';
|
|||||||
import { clearBuffer } from '../../ssh/crypto.js';
|
import { clearBuffer } from '../../ssh/crypto.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js';
|
import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js';
|
||||||
|
import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// Tool definitions
|
// Tool definitions
|
||||||
@ -62,13 +63,17 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
|||||||
function: {
|
function: {
|
||||||
name: 'SshConsoleSend',
|
name: 'SshConsoleSend',
|
||||||
description:
|
description:
|
||||||
'console に入力を送る。printable な shell コマンドには server が自動で末尾に "\\n" を付加して実行する (例: "ls -la" でも実行される)。TUI 操作 / sudo password / control byte (Ctrl-C 等) は raw のまま送られるので "\\n" を含めるかどうかは呼び出し側次第。connection_id はタスクに active session があれば省略可。詳細は ReadToolDoc({ name: "SshConsoleSend" })。',
|
'console に入力を送る。通常のシェルコマンド実行には SshConsoleRun を使うこと(blocking + exit_code 取得)。SshConsoleSend は対話操作 (vim/REPL/sudo/TUI) と本当の中断専用。printable な shell コマンドには server が自動で末尾に "\\n" を付加して実行する (例: "ls -la" でも実行される)。TUI 操作 / sudo password / control byte (Ctrl-C 等) は raw のまま送られるので "\\n" を含めるかどうかは呼び出し側次第。connection_id はタスクに active session があれば省略可。詳細は ReadToolDoc({ name: "SshConsoleSend" })。',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。明示する場合は active session の id と一致する必要あり (mismatch はエラー)。' },
|
connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。明示する場合は active session の id と一致する必要あり (mismatch はエラー)。' },
|
||||||
input: { type: 'string' },
|
input: { type: 'string' },
|
||||||
wait_ms: { type: 'number' },
|
wait_ms: { type: 'number' },
|
||||||
|
confirm_interrupt: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Ctrl-C (\\x03) を含む入力が interrupt_blocked=true で返った場合、true を付けて再送すると強制送信する。',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['input'],
|
required: ['input'],
|
||||||
},
|
},
|
||||||
@ -91,12 +96,31 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
SshConsoleRun: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'SshConsoleRun',
|
||||||
|
description:
|
||||||
|
'console でコマンドを実行し、完了まで待機して output と exit_code を返します(blocking)。通常のコマンドはこれを使う。長コマンドは timeout_ms を延ばすこと(max 600000)。詳細は ReadToolDoc({ name: "SshConsoleRun" })。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
command: { type: 'string', description: '実行するシェルコマンド。' },
|
||||||
|
connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。' },
|
||||||
|
timeout_ms: { type: 'number', description: 'タイムアウト(ms)。デフォルト 120000、最大 600000。タイムアウト時はコマンドを kill しない。' },
|
||||||
|
idle_ms: { type: 'number', description: '出力が途切れた後のアイドル判定時間(ms)。0=無効(デフォルト)。' },
|
||||||
|
},
|
||||||
|
required: ['command'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONSOLE_TOOL_NAMES = new Set([
|
const CONSOLE_TOOL_NAMES = new Set([
|
||||||
'SshConsoleEnsure',
|
'SshConsoleEnsure',
|
||||||
'SshConsoleSend',
|
'SshConsoleSend',
|
||||||
'SshConsoleSnapshot',
|
'SshConsoleSnapshot',
|
||||||
|
'SshConsoleRun',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
@ -516,6 +540,29 @@ async function sendInput(
|
|||||||
// Record bytes_before so we can compute new_output_bytes after wait.
|
// Record bytes_before so we can compute new_output_bytes after wait.
|
||||||
const outputBytesBefore = session.totalOutputBytes;
|
const outputBytesBefore = session.totalOutputBytes;
|
||||||
|
|
||||||
|
// Ctrl-C interrupt guard: if the payload contains \x03 and the session
|
||||||
|
// looks like it is still running (small idleMs / recent AI input /
|
||||||
|
// young session), block the write and ask the caller to confirm.
|
||||||
|
if (sendText.includes('\x03')) {
|
||||||
|
const liveness = {
|
||||||
|
idleMs: session.idleMs(),
|
||||||
|
msSinceAiInput: Date.now() - session.lastAiInputAt,
|
||||||
|
sessionAgeMs: Date.now() - session.startedAt,
|
||||||
|
};
|
||||||
|
if (shouldGuardInterrupt(liveness, input.confirm_interrupt === true)) {
|
||||||
|
return ok(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
interrupt_blocked: true,
|
||||||
|
require_confirm: true,
|
||||||
|
reason: 'command appears to still be running',
|
||||||
|
idle_ms: liveness.idleMs,
|
||||||
|
hint: 'To wait for completion use SshConsoleRun. To really abort, resend with confirm_interrupt:true.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Write to the session as 'ai' source. ConsoleSession.write forwards
|
// Write to the session as 'ai' source. ConsoleSession.write forwards
|
||||||
// bytes straight to the PTY so the shell echoes them back the same
|
// bytes straight to the PTY so the shell echoes them back the same
|
||||||
// way it does for human keystrokes — partial input is allowed and
|
// way it does for human keystrokes — partial input is allowed and
|
||||||
@ -565,6 +612,8 @@ async function sendInput(
|
|||||||
cursor: screen.cursor,
|
cursor: screen.cursor,
|
||||||
cols: screen.cols,
|
cols: screen.cols,
|
||||||
rows: screen.rows,
|
rows: screen.rows,
|
||||||
|
idle_ms: session.idleMs(),
|
||||||
|
maybe_waiting_for_input: detectWaitingForInput(screen.text),
|
||||||
...(autoNewlineAppended ? { auto_newline_appended: true } : {}),
|
...(autoNewlineAppended ? { auto_newline_appended: true } : {}),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@ -693,6 +742,210 @@ async function snapshot(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// SshConsoleRun
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Default timeout for SshConsoleRun: 2 minutes. */
|
||||||
|
const DEFAULT_RUN_TIMEOUT_MS = 120_000;
|
||||||
|
/** Maximum allowed timeout for SshConsoleRun: 10 minutes. */
|
||||||
|
const MAX_RUN_TIMEOUT_MS = 600_000;
|
||||||
|
/** Maximum output bytes returned to caller. */
|
||||||
|
const MAX_RUN_OUTPUT_BYTES = 32 * 1024;
|
||||||
|
/** Maximum screen_tail bytes returned to caller. */
|
||||||
|
const MAX_RUN_SCREEN_BYTES = 8 * 1024;
|
||||||
|
|
||||||
|
function clampTimeout(raw: unknown): number {
|
||||||
|
const v = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_RUN_TIMEOUT_MS;
|
||||||
|
return Math.min(MAX_RUN_TIMEOUT_MS, Math.max(1_000, Math.floor(v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function capTail(s: string, maxBytes: number): string {
|
||||||
|
if (s.length <= maxBytes) return s;
|
||||||
|
return s.slice(s.length - maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny ANSI stripper — delegates to the same patterns as ConsoleSession.stripAnsi. */
|
||||||
|
function stripAnsiSafe(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||||
|
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
||||||
|
.replace(/\x1b[@-Z\\^_]/g, '')
|
||||||
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunOutcome =
|
||||||
|
| { kind: 'done'; exitCode: number }
|
||||||
|
| { kind: 'human' }
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'timeout' };
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
ctx: ToolContext,
|
||||||
|
sub: SshSubsystem,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const command = typeof input.command === 'string' ? input.command.trim() : '';
|
||||||
|
if (!command) {
|
||||||
|
return err('SshConsoleRun: command is required (non-empty string).');
|
||||||
|
}
|
||||||
|
const localTaskId = ctx.taskId ?? '';
|
||||||
|
if (!localTaskId) {
|
||||||
|
return err('SshConsoleRun: this tool requires a local task context (ctx.taskId).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve connection_id: explicit arg or active session's id.
|
||||||
|
let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
|
||||||
|
const existingSession = sub.sessionRegistry.get(localTaskId);
|
||||||
|
if (!connectionId) {
|
||||||
|
if (!existingSession) {
|
||||||
|
return err(
|
||||||
|
'SshConsoleRun: connection_id is required when this task has no active session. ' +
|
||||||
|
'Call SshListConnections() to find the right UUID, then SshConsoleEnsure({connection_id}) to open one.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
connectionId = existingSession.connectionId;
|
||||||
|
} else if (existingSession && existingSession.connectionId !== connectionId) {
|
||||||
|
return err(
|
||||||
|
`SshConsoleRun: this task has an active session on connection ${existingSession.connectionId}, not ${connectionId}. ` +
|
||||||
|
`Either omit connection_id (uses the active one) or pass connection_id="${existingSession.connectionId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find-or-open session.
|
||||||
|
const ensured = await ensureSessionInternal({ connection_id: connectionId }, ctx, sub);
|
||||||
|
if ('isError' in ensured) return ensured;
|
||||||
|
const session = ensured.session;
|
||||||
|
|
||||||
|
// Preflight for audit + access check.
|
||||||
|
const pre = preflight({
|
||||||
|
toolName: 'SshExec',
|
||||||
|
connectionId,
|
||||||
|
ctx,
|
||||||
|
sub,
|
||||||
|
auditAction: 'ssh.console.run',
|
||||||
|
});
|
||||||
|
if (!pre.ok) return pre.error;
|
||||||
|
const { connection, actingUserId, pieceName } = pre;
|
||||||
|
|
||||||
|
// Deny-check the command.
|
||||||
|
const denyResult = checkConsoleInput(
|
||||||
|
command + '\n',
|
||||||
|
connection.commandDenyPatterns ? connection.commandDenyPatterns.split('\n') : null,
|
||||||
|
connection.commandAllowPatterns ? connection.commandAllowPatterns.split('\n') : null,
|
||||||
|
);
|
||||||
|
if (!denyResult.ok) {
|
||||||
|
sub.auditRepo.beginAndComplete(
|
||||||
|
{
|
||||||
|
action: 'ssh.console.run',
|
||||||
|
connectionId,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
actingUserId,
|
||||||
|
pieceName,
|
||||||
|
jobId: ctx.jobId ?? undefined,
|
||||||
|
detail: {
|
||||||
|
reason: denyResult.reason,
|
||||||
|
line_index: denyResult.lineIndex,
|
||||||
|
matched: denyResult.matched,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'denied',
|
||||||
|
);
|
||||||
|
return err(
|
||||||
|
`SshConsoleRun: command rejected by ${denyResult.reason} (pattern ${denyResult.matched ?? 'n/a'}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the marker command.
|
||||||
|
const nonce = makeNonce();
|
||||||
|
const { text: markerText } = makeMarkerCommand(command, nonce);
|
||||||
|
const timeoutMs = clampTimeout(input.timeout_ms);
|
||||||
|
const idleMs = typeof input.idle_ms === 'number' && Number.isFinite(input.idle_ms) ? Math.max(0, Math.floor(input.idle_ms)) : 0;
|
||||||
|
|
||||||
|
const startAt = Date.now();
|
||||||
|
const humanBaseline = session.lastHumanInputAt;
|
||||||
|
const scrollStart = session.scrollbackBytes().length;
|
||||||
|
|
||||||
|
// Block until done, human interrupt, idle, or timeout.
|
||||||
|
const result = await new Promise<RunOutcome>((resolve) => {
|
||||||
|
let acc = '';
|
||||||
|
let lastChunkAt = Date.now();
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const finish = (outcome: RunOutcome) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(outcome);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsub = session.onOutput((chunk: Buffer) => {
|
||||||
|
acc += chunk.toString('utf8');
|
||||||
|
lastChunkAt = Date.now();
|
||||||
|
const m = parseMarker(stripAnsiSafe(acc), nonce);
|
||||||
|
if (m.found) finish({ kind: 'done', exitCode: m.exitCode! });
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (session.lastHumanInputAt > humanBaseline) return finish({ kind: 'human' });
|
||||||
|
if (idleMs > 0 && Date.now() - lastChunkAt >= idleMs) return finish({ kind: 'idle' });
|
||||||
|
if (Date.now() - startAt >= timeoutMs) return finish({ kind: 'timeout' });
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
unsub();
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send the marker command to the session.
|
||||||
|
session.write(Buffer.from(markerText, 'utf8'), 'ai');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build output from scrollback since scrollStart.
|
||||||
|
const rawAll = session.scrollbackBytes().slice(scrollStart).toString('utf8');
|
||||||
|
const output = capTail(extractOutput(stripAnsiSafe(rawAll), command, nonce), MAX_RUN_OUTPUT_BYTES);
|
||||||
|
const screen = session.snapshotScreen();
|
||||||
|
const screenTail = capTail(screen.text, MAX_RUN_SCREEN_BYTES);
|
||||||
|
|
||||||
|
sub.auditRepo.beginAndComplete(
|
||||||
|
{
|
||||||
|
action: 'ssh.console.run',
|
||||||
|
connectionId,
|
||||||
|
ownerId: connection.ownerId,
|
||||||
|
actingUserId,
|
||||||
|
pieceName,
|
||||||
|
jobId: ctx.jobId ?? undefined,
|
||||||
|
detail: {
|
||||||
|
command,
|
||||||
|
timeout_ms: timeoutMs,
|
||||||
|
outcome: result.kind,
|
||||||
|
exit_code: result.kind === 'done' ? result.exitCode : null,
|
||||||
|
output_bytes: output.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
result.kind === 'done' ? 'success' : 'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
done: result.kind === 'done',
|
||||||
|
exit_code: result.kind === 'done' ? result.exitCode : null,
|
||||||
|
output,
|
||||||
|
screen_tail: screenTail,
|
||||||
|
idle_ms: session.idleMs(),
|
||||||
|
interrupted_by: result.kind === 'human' ? 'human' : null,
|
||||||
|
timed_out: result.kind === 'timeout',
|
||||||
|
maybe_waiting_for_input: detectWaitingForInput(screenTail),
|
||||||
|
marker_found: result.kind === 'done',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// Dispatcher
|
// Dispatcher
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
@ -713,5 +966,6 @@ export async function executeTool(
|
|||||||
if (name === 'SshConsoleEnsure') return ensureTool(input, ctx, subsystem);
|
if (name === 'SshConsoleEnsure') return ensureTool(input, ctx, subsystem);
|
||||||
if (name === 'SshConsoleSend') return sendInput(input, ctx, subsystem);
|
if (name === 'SshConsoleSend') return sendInput(input, ctx, subsystem);
|
||||||
if (name === 'SshConsoleSnapshot') return snapshot(input, ctx, subsystem);
|
if (name === 'SshConsoleSnapshot') return snapshot(input, ctx, subsystem);
|
||||||
|
if (name === 'SshConsoleRun') return run(input, ctx, subsystem);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,7 +83,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
|
|||||||
// ssh.ts
|
// ssh.ts
|
||||||
'SshDownload', 'SshExec', 'SshListConnections', 'SshUpload',
|
'SshDownload', 'SshExec', 'SshListConnections', 'SshUpload',
|
||||||
// ssh-console.ts
|
// ssh-console.ts
|
||||||
'SshConsoleEnsure', 'SshConsoleSendKeys', 'SshConsoleSnapshot',
|
'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', 'SshConsoleRun',
|
||||||
// notes.ts
|
// notes.ts
|
||||||
'ReadNote', 'SearchNotes', 'WriteNote',
|
'ReadNote', 'SearchNotes', 'WriteNote',
|
||||||
// dashboard.ts
|
// dashboard.ts
|
||||||
|
|||||||
@ -164,6 +164,22 @@ describe('ConsoleSession', () => {
|
|||||||
expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
|
expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tracks last output / ai-input / human-input timestamps separately', () => {
|
||||||
|
const ch = new StubChannel();
|
||||||
|
const { session } = mkSession(ch);
|
||||||
|
const t0 = session.lastOutputAt;
|
||||||
|
// simulate output
|
||||||
|
(session as any).handleOutput(Buffer.from('hi'));
|
||||||
|
expect(session.lastOutputAt).toBeGreaterThanOrEqual(t0);
|
||||||
|
session.write(Buffer.from('a'), 'ai');
|
||||||
|
expect(session.lastAiInputAt).toBeGreaterThan(0);
|
||||||
|
const aiAt = session.lastAiInputAt;
|
||||||
|
session.write(Buffer.from('b'), 'human');
|
||||||
|
expect(session.lastHumanInputAt).toBeGreaterThan(0);
|
||||||
|
expect(session.lastAiInputAt).toBe(aiAt); // human write must not move ai timestamp
|
||||||
|
expect(typeof session.idleMs()).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
it('client "close" tears the session down once', async () => {
|
it('client "close" tears the session down once', async () => {
|
||||||
const ch = new StubChannel();
|
const ch = new StubChannel();
|
||||||
const { session, audit, client } = mkSession(ch);
|
const { session, audit, client } = mkSession(ch);
|
||||||
|
|||||||
@ -121,6 +121,9 @@ export class ConsoleSession {
|
|||||||
private readonly auditRepo: SshAuditRepo;
|
private readonly auditRepo: SshAuditRepo;
|
||||||
|
|
||||||
private _lastActivityAt: number;
|
private _lastActivityAt: number;
|
||||||
|
private _lastOutputAt: number;
|
||||||
|
private _lastAiInputAt = 0;
|
||||||
|
private _lastHumanInputAt = 0;
|
||||||
private _totalInputBytes = 0;
|
private _totalInputBytes = 0;
|
||||||
private _totalOutputBytes = 0;
|
private _totalOutputBytes = 0;
|
||||||
|
|
||||||
@ -136,6 +139,7 @@ export class ConsoleSession {
|
|||||||
this.startedByUserId = args.startedByUserId;
|
this.startedByUserId = args.startedByUserId;
|
||||||
this.startedAt = Date.now();
|
this.startedAt = Date.now();
|
||||||
this._lastActivityAt = this.startedAt;
|
this._lastActivityAt = this.startedAt;
|
||||||
|
this._lastOutputAt = this.startedAt;
|
||||||
this.cols = args.cols;
|
this.cols = args.cols;
|
||||||
this.rows = args.rows;
|
this.rows = args.rows;
|
||||||
this.channel = args.channel;
|
this.channel = args.channel;
|
||||||
@ -181,6 +185,18 @@ export class ConsoleSession {
|
|||||||
get lastActivityAt(): number {
|
get lastActivityAt(): number {
|
||||||
return this._lastActivityAt;
|
return this._lastActivityAt;
|
||||||
}
|
}
|
||||||
|
get lastOutputAt(): number {
|
||||||
|
return this._lastOutputAt;
|
||||||
|
}
|
||||||
|
get lastAiInputAt(): number {
|
||||||
|
return this._lastAiInputAt;
|
||||||
|
}
|
||||||
|
get lastHumanInputAt(): number {
|
||||||
|
return this._lastHumanInputAt;
|
||||||
|
}
|
||||||
|
idleMs(): number {
|
||||||
|
return Date.now() - this._lastOutputAt;
|
||||||
|
}
|
||||||
get totalInputBytes(): number {
|
get totalInputBytes(): number {
|
||||||
return this._totalInputBytes;
|
return this._totalInputBytes;
|
||||||
}
|
}
|
||||||
@ -239,6 +255,8 @@ export class ConsoleSession {
|
|||||||
// Partial input (no newline) is forwarded so the shell can echo each
|
// Partial input (no newline) is forwarded so the shell can echo each
|
||||||
// character back, matching the live terminal experience the user
|
// character back, matching the live terminal experience the user
|
||||||
// expects in either role.
|
// expects in either role.
|
||||||
|
if (source === 'ai') this._lastAiInputAt = Date.now();
|
||||||
|
else this._lastHumanInputAt = Date.now();
|
||||||
const out = source === 'ai' ? normalizeLfToCr(buf) : buf;
|
const out = source === 'ai' ? normalizeLfToCr(buf) : buf;
|
||||||
this._totalInputBytes += out.length;
|
this._totalInputBytes += out.length;
|
||||||
const ok = this.channel.write(out);
|
const ok = this.channel.write(out);
|
||||||
@ -338,6 +356,7 @@ export class ConsoleSession {
|
|||||||
private handleOutput(data: Buffer): void {
|
private handleOutput(data: Buffer): void {
|
||||||
this._totalOutputBytes += data.length;
|
this._totalOutputBytes += data.length;
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
|
this._lastOutputAt = Date.now();
|
||||||
this.scrollback.append(data);
|
this.scrollback.append(data);
|
||||||
this.writeToHeadlessSync(data);
|
this.writeToHeadlessSync(data);
|
||||||
for (const l of this.outputListeners) {
|
for (const l of this.outputListeners) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, type ReactNode } from 'react';
|
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { LocalTask, type Visibility } from './api';
|
import { LocalTask, type Visibility } from './api';
|
||||||
import { useUrlState } from './hooks/useUrlState';
|
import { useUrlState } from './hooks/useUrlState';
|
||||||
@ -41,6 +41,10 @@ import { UserFolderTab } from './components/userfolder/UserFolderTab';
|
|||||||
import { HelpPage } from './pages/HelpPage';
|
import { HelpPage } from './pages/HelpPage';
|
||||||
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
|
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
|
||||||
import type { ConsoleStatus } from './lib/ssh-console-types';
|
import type { ConsoleStatus } from './lib/ssh-console-types';
|
||||||
|
import { CommandPalette } from './components/command/CommandPalette';
|
||||||
|
import { shouldOpenForKeyEvent, type CommandContext } from './lib/command-palette';
|
||||||
|
import { setThemePref } from './lib/theme';
|
||||||
|
import { shouldAutoFocus } from './lib/live-workspace';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
@ -135,6 +139,23 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
'ui.focusedChatPx',
|
'ui.focusedChatPx',
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
// Live workspace: auto-enter focused layout while a browser/ssh tab is open.
|
||||||
|
// A manual toggle/exit wins for the current view via `override`, which is
|
||||||
|
// KEYED to the current (tab,task): when the key changes the stale override is
|
||||||
|
// ignored at derivation time (no effect), so re-entering a live tab
|
||||||
|
// re-auto-focuses immediately with no stale-frame flicker.
|
||||||
|
const [override, setOverride] = useState<{ key: string; value: boolean } | null>(null);
|
||||||
|
const overrideKey = `${detailTab}:${localTaskId ?? ''}`;
|
||||||
|
// Key-match at derivation time = the leaving frame is immediately correct
|
||||||
|
// (no stale flash). The effect then clears the stale override so RETURNING to
|
||||||
|
// the same (tab,task) later re-auto-focuses instead of re-applying it.
|
||||||
|
const activeOverride = override && override.key === overrideKey ? override.value : null;
|
||||||
|
const isFocused =
|
||||||
|
activeOverride ?? (shouldAutoFocus(detailTab, localTaskId != null) || detailWidth === 'focused');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOverride((o) => (o && o.key === overrideKey ? o : null));
|
||||||
|
}, [overrideKey]);
|
||||||
const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
|
const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
|
||||||
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
||||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||||
@ -148,7 +169,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
|
|
||||||
// Shared navigation handler used by both TopBar and NavDrawer.
|
// Shared navigation handler used by both TopBar and NavDrawer.
|
||||||
// Guards against discarding unsaved edits before switching pages.
|
// Guards against discarding unsaved edits before switching pages.
|
||||||
const handleNavigatePage = (p: PageId) => {
|
const handleNavigatePage = useCallback((p: PageId) => {
|
||||||
if (p === page) {
|
if (p === page) {
|
||||||
setNavDrawerOpen(false);
|
setNavDrawerOpen(false);
|
||||||
return;
|
return;
|
||||||
@ -156,7 +177,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
if (!confirmDiscardUnsaved()) return;
|
if (!confirmDiscardUnsaved()) return;
|
||||||
setUrlState(prev => ({ ...prev, page: p }));
|
setUrlState(prev => ({ ...prev, page: p }));
|
||||||
setNavDrawerOpen(false);
|
setNavDrawerOpen(false);
|
||||||
};
|
}, [page, setUrlState]);
|
||||||
|
|
||||||
const edgeSwipe = useEdgeSwipe({
|
const edgeSwipe = useEdgeSwipe({
|
||||||
enabled: compactMode && !navDrawerOpen,
|
enabled: compactMode && !navDrawerOpen,
|
||||||
@ -189,6 +210,38 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
// its own when mounted.
|
// its own when mounted.
|
||||||
const localTasksQuery = useLocalTaskList();
|
const localTasksQuery = useLocalTaskList();
|
||||||
|
|
||||||
|
// ⌘K command palette
|
||||||
|
const [cmdkOpen, setCmdkOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const el = document.activeElement as Element | null;
|
||||||
|
const inTerminal = !!el?.closest('.xterm');
|
||||||
|
// Monaco / contenteditable bind ⌘K/Ctrl-K as chord prefixes — don't steal.
|
||||||
|
const inEditor = !!el?.closest('.monaco-editor, [contenteditable="true"]');
|
||||||
|
if (shouldOpenForKeyEvent(e, { inTerminal, inEditor })) {
|
||||||
|
e.preventDefault();
|
||||||
|
setCmdkOpen((o) => !o);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cmdkCtx: CommandContext = useMemo(() => ({
|
||||||
|
navigatePage: handleNavigatePage,
|
||||||
|
openTask: (id: number) => {
|
||||||
|
// Opening a task can change page (e.g. from settings) → run the same
|
||||||
|
// unsaved-changes guard handleNavigatePage uses, so we don't silently
|
||||||
|
// discard an in-progress edit.
|
||||||
|
if (!confirmDiscardUnsaved()) return;
|
||||||
|
setUrlState((prev) => ({ ...prev, page: 'tasks', taskId: id }));
|
||||||
|
},
|
||||||
|
setTheme: setThemePref,
|
||||||
|
navItems: visibleNav.map((n) => ({ id: n.id, label: n.label })),
|
||||||
|
tasks: (localTasksQuery.data ?? []).map((t) => ({ id: t.id, title: t.title })),
|
||||||
|
}), [handleNavigatePage, setUrlState, visibleNav, localTasksQuery.data]);
|
||||||
|
|
||||||
// ブラウザ通知設定 (localStorage) — 設定 UI は NotificationsForm が管理
|
// ブラウザ通知設定 (localStorage) — 設定 UI は NotificationsForm が管理
|
||||||
const [notifyEnabled] = useLocalStorageState<boolean>('notify.enabled', true);
|
const [notifyEnabled] = useLocalStorageState<boolean>('notify.enabled', true);
|
||||||
const [notifyEvents] = useLocalStorageState<NotifyEventSettings>(
|
const [notifyEvents] = useLocalStorageState<NotifyEventSettings>(
|
||||||
@ -315,10 +368,14 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
pathSegments: fileBrowser.pathSegments,
|
pathSegments: fileBrowser.pathSegments,
|
||||||
loading: localTaskQuery.isLoading,
|
loading: localTaskQuery.isLoading,
|
||||||
detailTab: overrides?.detailTab ?? detailTab,
|
detailTab: overrides?.detailTab ?? detailTab,
|
||||||
detailWidth,
|
detailWidth: (isFocused ? 'focused' : 'normal') as 'normal' | 'focused',
|
||||||
showWidthToggle: overrides?.showWidthToggle ?? true,
|
showWidthToggle: overrides?.showWidthToggle ?? true,
|
||||||
onTabChange: overrides?.onTabChange ?? (t => setUrlState(prev => ({ ...prev, detailTab: t }))),
|
onTabChange: overrides?.onTabChange ?? (t => setUrlState(prev => ({ ...prev, detailTab: t }))),
|
||||||
onWidthToggle: () => setDetailWidth(prev => prev === 'focused' ? 'normal' : 'focused'),
|
onWidthToggle: () => {
|
||||||
|
const next = !isFocused;
|
||||||
|
setOverride({ key: overrideKey, value: next });
|
||||||
|
setDetailWidth(next ? 'focused' : 'normal');
|
||||||
|
},
|
||||||
onClose: overrides?.onClose ?? (() => setUrlState(prev => ({ ...prev, taskId: null, detailTab: 'overview' }))),
|
onClose: overrides?.onClose ?? (() => setUrlState(prev => ({ ...prev, taskId: null, detailTab: 'overview' }))),
|
||||||
onDelete: handleDelete,
|
onDelete: handleDelete,
|
||||||
onSectionChange: fileBrowser.setSection,
|
onSectionChange: fileBrowser.setSection,
|
||||||
@ -335,7 +392,6 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
// Layout calculation
|
// Layout calculation
|
||||||
const sidebarWidth = 'clamp(240px, 22vw, 280px)';
|
const sidebarWidth = 'clamp(240px, 22vw, 280px)';
|
||||||
const detailPanelWidth = 'clamp(280px, 26vw, 440px)'; // normal mode 時のみ使用
|
const detailPanelWidth = 'clamp(280px, 26vw, 440px)'; // normal mode 時のみ使用
|
||||||
const isFocused = detailWidth === 'focused';
|
|
||||||
const RAIL_PX = 48;
|
const RAIL_PX = 48;
|
||||||
const HANDLE_PX = 4;
|
const HANDLE_PX = 4;
|
||||||
const MIN_CHAT_PX = 280;
|
const MIN_CHAT_PX = 280;
|
||||||
@ -384,6 +440,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
onOpenDrawer={openNavDrawer}
|
onOpenDrawer={openNavDrawer}
|
||||||
hamburgerButtonRef={hamburgerRef}
|
hamburgerButtonRef={hamburgerRef}
|
||||||
navDrawerOpen={navDrawerOpen}
|
navDrawerOpen={navDrawerOpen}
|
||||||
|
onOpenCommandK={() => setCmdkOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
||||||
@ -521,7 +578,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
? <TaskListPanel
|
? <TaskListPanel
|
||||||
{...taskListProps}
|
{...taskListProps}
|
||||||
mode="rail"
|
mode="rail"
|
||||||
onExitFocused={() => setDetailWidth('normal')}
|
onExitFocused={() => { setOverride({ key: overrideKey, value: false }); setDetailWidth('normal'); }}
|
||||||
/>
|
/>
|
||||||
: <div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} mode="list" /></div>
|
: <div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} mode="list" /></div>
|
||||||
}
|
}
|
||||||
@ -616,6 +673,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
logoUrl={branding.logoUrl}
|
logoUrl={branding.logoUrl}
|
||||||
returnFocusRef={hamburgerRef}
|
returnFocusRef={hamburgerRef}
|
||||||
/>
|
/>
|
||||||
|
<CommandPalette open={cmdkOpen} onClose={() => setCmdkOpen(false)} ctx={cmdkCtx} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
121
ui/src/components/command/CommandPalette.tsx
Normal file
121
ui/src/components/command/CommandPalette.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
buildCommands, filterCommands, groupCommands,
|
||||||
|
type CommandContext, type CommandItem,
|
||||||
|
} from '../../lib/command-palette';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
ctx: CommandContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({ open, onClose, ctx }: Props) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const openerRef = useRef<Element | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [highlight, setHighlight] = useState(0);
|
||||||
|
|
||||||
|
const allCommands = useMemo(() => buildCommands(ctx), [ctx]);
|
||||||
|
const results = useMemo(() => filterCommands(allCommands, query), [allCommands, query]);
|
||||||
|
const groups = useMemo(() => groupCommands(results), [results]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dlg = dialogRef.current;
|
||||||
|
if (!dlg) return;
|
||||||
|
if (open && !dlg.open) {
|
||||||
|
openerRef.current = document.activeElement;
|
||||||
|
setQuery('');
|
||||||
|
setHighlight(0);
|
||||||
|
dlg.showModal();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
} else if (!open && dlg.open) {
|
||||||
|
dlg.close();
|
||||||
|
const o = openerRef.current;
|
||||||
|
if (o instanceof HTMLElement && o.isConnected) o.focus();
|
||||||
|
else document.body.focus();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dlg = dialogRef.current;
|
||||||
|
if (!dlg) return;
|
||||||
|
const onCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||||
|
const onClick = (e: MouseEvent) => { if (e.target === dlg) onClose(); };
|
||||||
|
dlg.addEventListener('cancel', onCancel);
|
||||||
|
dlg.addEventListener('click', onClick);
|
||||||
|
return () => { dlg.removeEventListener('cancel', onCancel); dlg.removeEventListener('click', onClick); };
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
useEffect(() => { setHighlight(0); }, [query]);
|
||||||
|
|
||||||
|
const flat = results;
|
||||||
|
const highlightedId = flat[highlight]?.id;
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, flat.length - 1)); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); }
|
||||||
|
else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = flat[highlight];
|
||||||
|
if (item) { item.run(); onClose(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightedId) return;
|
||||||
|
document.getElementById(`cmdk-opt-${highlightedId}`)?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [highlightedId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
aria-label="コマンドパレット"
|
||||||
|
className="m-0 mt-[12vh] mx-auto w-[min(560px,92vw)] rounded-xl border border-hairline bg-surface text-ink shadow-2xl p-0 backdrop:bg-black/40"
|
||||||
|
>
|
||||||
|
<div className="p-2 border-b border-hairline">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="cmdk-listbox"
|
||||||
|
aria-label="コマンド・タスクを検索"
|
||||||
|
aria-activedescendant={highlightedId ? `cmdk-opt-${highlightedId}` : undefined}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="コマンド・タスクを検索…"
|
||||||
|
className="w-full h-9 px-2 bg-transparent outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="cmdk-listbox" role="listbox" className="max-h-[52vh] overflow-y-auto p-1">
|
||||||
|
{flat.length === 0 && <div role="status" className="px-3 py-6 text-center text-sm text-muted">該当なし</div>}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div key={g.group} role="group" aria-label={g.label}>
|
||||||
|
<div className="section-label px-2 pt-2 pb-1">{g.label}</div>
|
||||||
|
{g.items.map((item: CommandItem) => {
|
||||||
|
const active = item.id === highlightedId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
id={`cmdk-opt-${item.id}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
onMouseMove={() => setHighlight(flat.indexOf(item))}
|
||||||
|
onClick={() => { item.run(); onClose(); }}
|
||||||
|
className={`flex items-center justify-between gap-2 px-2.5 h-9 rounded-md cursor-pointer text-sm ${
|
||||||
|
active ? 'bg-surface-2 text-ink' : 'text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
{item.hint && <span className="text-2xs text-muted flex-shrink-0">{item.hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { type ThemePref, readStoredTheme, setThemePref } from '../../lib/theme';
|
import { type ThemePref, isThemePref, readStoredTheme, setThemePref, THEME_CHANGE_EVENT } from '../../lib/theme';
|
||||||
|
|
||||||
const ICON_PROPS = {
|
const ICON_PROPS = {
|
||||||
width: 14,
|
width: 14,
|
||||||
@ -56,6 +56,17 @@ const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
|
|||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const [pref, setPref] = useState<ThemePref>(() => readStoredTheme());
|
const [pref, setPref] = useState<ThemePref>(() => readStoredTheme());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = (e: Event) => {
|
||||||
|
// Prefer the pref carried in the event (works even if localStorage write
|
||||||
|
// failed); fall back to stored value for plain Event dispatches.
|
||||||
|
const detail = (e as CustomEvent<unknown>).detail;
|
||||||
|
setPref(isThemePref(detail) ? detail : readStoredTheme());
|
||||||
|
};
|
||||||
|
window.addEventListener(THEME_CHANGE_EVENT, sync);
|
||||||
|
return () => window.removeEventListener(THEME_CHANGE_EVENT, sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const choose = (value: ThemePref) => {
|
const choose = (value: ThemePref) => {
|
||||||
setPref(value);
|
setPref(value);
|
||||||
setThemePref(value);
|
setThemePref(value);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ interface TopBarProps {
|
|||||||
onOpenDrawer: () => void;
|
onOpenDrawer: () => void;
|
||||||
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||||
navDrawerOpen?: boolean;
|
navDrawerOpen?: boolean;
|
||||||
|
onOpenCommandK?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
||||||
@ -69,6 +70,7 @@ export function TopBar({
|
|||||||
onOpenDrawer,
|
onOpenDrawer,
|
||||||
hamburgerButtonRef,
|
hamburgerButtonRef,
|
||||||
navDrawerOpen = false,
|
navDrawerOpen = false,
|
||||||
|
onOpenCommandK,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||||
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
|
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
|
||||||
@ -138,6 +140,17 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{onOpenCommandK && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenCommandK}
|
||||||
|
aria-label="コマンドパレットを開く"
|
||||||
|
title="コマンドパレット (⌘K)"
|
||||||
|
className="hidden sm:inline-flex items-center gap-1 px-2 h-7 rounded-md border border-hairline text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden>⌘K</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
81
ui/src/lib/command-palette.test.ts
Normal file
81
ui/src/lib/command-palette.test.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { buildCommands, filterCommands, groupCommands, shouldOpenForKeyEvent, type CommandContext } from './command-palette';
|
||||||
|
|
||||||
|
function ctx(over: Partial<CommandContext> = {}): CommandContext {
|
||||||
|
return {
|
||||||
|
navigatePage: vi.fn(), openTask: vi.fn(), setTheme: vi.fn(),
|
||||||
|
navItems: [{ id: 'tasks', label: 'タスク' }, { id: 'settings', label: '設定' }],
|
||||||
|
tasks: [{ id: 407, title: 'PR 407 fix' }, { id: 12, title: 'UI resize' }],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildCommands', () => {
|
||||||
|
it('emits nav + 3 theme + task commands with correct ids/keywords', () => {
|
||||||
|
const items = buildCommands(ctx());
|
||||||
|
expect(items.filter(i => i.id.startsWith('nav:')).length).toBe(2);
|
||||||
|
expect(items.filter(i => i.id.startsWith('theme:')).length).toBe(3);
|
||||||
|
const task = items.find(i => i.id === 'task:407')!;
|
||||||
|
expect(task.group).toBe('task');
|
||||||
|
expect(task.keywords).toBe('#407');
|
||||||
|
expect(task.hint).toBe('#407');
|
||||||
|
});
|
||||||
|
it('run() invokes the right context fn', () => {
|
||||||
|
const c = ctx();
|
||||||
|
const items = buildCommands(c);
|
||||||
|
items.find(i => i.id === 'nav:settings')!.run();
|
||||||
|
expect(c.navigatePage).toHaveBeenCalledWith('settings');
|
||||||
|
items.find(i => i.id === 'theme:dark')!.run();
|
||||||
|
expect(c.setTheme).toHaveBeenCalledWith('dark');
|
||||||
|
items.find(i => i.id === 'task:407')!.run();
|
||||||
|
expect(c.openTask).toHaveBeenCalledWith(407);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterCommands', () => {
|
||||||
|
it('empty query: all nav/theme + first 5 tasks', () => {
|
||||||
|
const many = ctx({ tasks: Array.from({ length: 9 }, (_, i) => ({ id: i + 1, title: `t${i + 1}` })) });
|
||||||
|
const out = filterCommands(buildCommands(many), '');
|
||||||
|
expect(out.filter(i => i.group === 'task').length).toBe(5);
|
||||||
|
expect(out.filter(i => i.group === 'nav').length).toBe(2 + 3);
|
||||||
|
});
|
||||||
|
it('matches label and #id, prefix ranks first', () => {
|
||||||
|
const out = filterCommands(buildCommands(ctx()), '設定');
|
||||||
|
expect(out[0].id).toBe('nav:settings');
|
||||||
|
const byId = filterCommands(buildCommands(ctx()), '#407');
|
||||||
|
expect(byId.some(i => i.id === 'task:407')).toBe(true);
|
||||||
|
});
|
||||||
|
it('no match → empty', () => {
|
||||||
|
expect(filterCommands(buildCommands(ctx()), 'zzzzz')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupCommands', () => {
|
||||||
|
it('orders nav before task and drops empty groups', () => {
|
||||||
|
const groups = groupCommands(filterCommands(buildCommands(ctx()), ''));
|
||||||
|
expect(groups.map(g => g.group)).toEqual(['nav', 'task']);
|
||||||
|
const navOnly = groupCommands(filterCommands(buildCommands(ctx()), '設定'));
|
||||||
|
expect(navOnly.map(g => g.group)).toEqual(['nav']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldOpenForKeyEvent', () => {
|
||||||
|
const base = { metaKey: false, ctrlKey: false, key: 'k', isComposing: false };
|
||||||
|
it('opens on meta+k and ctrl+k', () => {
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: false, inEditor: false })).toBe(true);
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: false, inEditor: false })).toBe(true);
|
||||||
|
});
|
||||||
|
it('ignores IME composition and non-k and bare k', () => {
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, metaKey: true, isComposing: true }, { inTerminal: false, inEditor: false })).toBe(false);
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, metaKey: true, key: 'j' }, { inTerminal: false, inEditor: false })).toBe(false);
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, key: 'k' }, { inTerminal: false, inEditor: false })).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not steal ctrl+k from a focused terminal, but meta+k still works', () => {
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: true, inEditor: false })).toBe(false);
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: true, inEditor: false })).toBe(true);
|
||||||
|
});
|
||||||
|
it('never steals from a rich editor (Monaco/contenteditable): both combos blocked', () => {
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, metaKey: true }, { inTerminal: false, inEditor: true })).toBe(false);
|
||||||
|
expect(shouldOpenForKeyEvent({ ...base, ctrlKey: true }, { inTerminal: false, inEditor: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
ui/src/lib/command-palette.ts
Normal file
92
ui/src/lib/command-palette.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import type { PageId } from './urlState';
|
||||||
|
import type { ThemePref } from './theme';
|
||||||
|
|
||||||
|
export type CommandGroup = 'nav' | 'task';
|
||||||
|
|
||||||
|
export interface CommandItem {
|
||||||
|
id: string;
|
||||||
|
group: CommandGroup;
|
||||||
|
label: string;
|
||||||
|
keywords?: string;
|
||||||
|
hint?: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandContext {
|
||||||
|
navigatePage: (p: PageId) => void;
|
||||||
|
openTask: (id: number) => void;
|
||||||
|
setTheme: (pref: ThemePref) => void;
|
||||||
|
navItems: Array<{ id: PageId; label: string }>;
|
||||||
|
tasks: Array<{ id: number; title: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEMES: Array<{ pref: ThemePref; label: string }> = [
|
||||||
|
{ pref: 'system', label: 'テーマ: システム' },
|
||||||
|
{ pref: 'light', label: 'テーマ: ライト' },
|
||||||
|
{ pref: 'dark', label: 'テーマ: ダーク' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_TASK_LIMIT = 5;
|
||||||
|
|
||||||
|
export function buildCommands(ctx: CommandContext): CommandItem[] {
|
||||||
|
const nav: CommandItem[] = ctx.navItems.map((n) => ({
|
||||||
|
id: `nav:${n.id}`, group: 'nav', label: n.label, run: () => ctx.navigatePage(n.id),
|
||||||
|
}));
|
||||||
|
const theme: CommandItem[] = THEMES.map((t) => ({
|
||||||
|
id: `theme:${t.pref}`, group: 'nav', label: t.label, run: () => ctx.setTheme(t.pref),
|
||||||
|
}));
|
||||||
|
const tasks: CommandItem[] = ctx.tasks.map((t) => ({
|
||||||
|
id: `task:${t.id}`, group: 'task', label: t.title, keywords: `#${t.id}`, hint: `#${t.id}`,
|
||||||
|
run: () => ctx.openTask(t.id),
|
||||||
|
}));
|
||||||
|
return [...nav, ...theme, ...tasks];
|
||||||
|
}
|
||||||
|
|
||||||
|
function score(hay: string, q: string): number {
|
||||||
|
const i = hay.indexOf(q);
|
||||||
|
if (i === -1) return 0;
|
||||||
|
if (i === 0) return 3;
|
||||||
|
if (hay.includes(' ' + q)) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterCommands(items: CommandItem[], query: string): CommandItem[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
const nav = items.filter((i) => i.group === 'nav');
|
||||||
|
const tasks = items.filter((i) => i.group === 'task').slice(0, EMPTY_TASK_LIMIT);
|
||||||
|
return [...nav, ...tasks];
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.map((i) => ({ i, s: score(`${i.label} ${i.keywords ?? ''}`.toLowerCase(), q) }))
|
||||||
|
.filter((x) => x.s > 0)
|
||||||
|
.sort((a, b) => b.s - a.s)
|
||||||
|
.map((x) => x.i);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupCommands(
|
||||||
|
items: CommandItem[],
|
||||||
|
): Array<{ group: CommandGroup; label: string; items: CommandItem[] }> {
|
||||||
|
const order: Array<{ group: CommandGroup; label: string }> = [
|
||||||
|
{ group: 'nav', label: 'ナビゲーション' },
|
||||||
|
{ group: 'task', label: 'タスク' },
|
||||||
|
];
|
||||||
|
return order
|
||||||
|
.map((g) => ({ ...g, items: items.filter((i) => i.group === g.group) }))
|
||||||
|
.filter((g) => g.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldOpenForKeyEvent(
|
||||||
|
e: { metaKey: boolean; ctrlKey: boolean; key: string; isComposing?: boolean },
|
||||||
|
opts: { inTerminal: boolean; inEditor: boolean },
|
||||||
|
): boolean {
|
||||||
|
if (e.isComposing) return false;
|
||||||
|
if (e.key.toLowerCase() !== 'k') return false;
|
||||||
|
if (!(e.metaKey || e.ctrlKey)) return false;
|
||||||
|
// Rich editors bind ⌘K/Ctrl-K as a chord prefix (Monaco, contenteditable):
|
||||||
|
// never steal either combo from them.
|
||||||
|
if (opts.inEditor) return false;
|
||||||
|
// Terminal: only Ctrl-K is the shell binding (kill-line); ⌘K is free to open.
|
||||||
|
if (e.ctrlKey && !e.metaKey && opts.inTerminal) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
19
ui/src/lib/live-workspace.test.ts
Normal file
19
ui/src/lib/live-workspace.test.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { shouldAutoFocus } from './live-workspace';
|
||||||
|
|
||||||
|
describe('shouldAutoFocus', () => {
|
||||||
|
it('true only for browser/ssh tabs with a selected task', () => {
|
||||||
|
expect(shouldAutoFocus('browser', true)).toBe(true);
|
||||||
|
expect(shouldAutoFocus('ssh', true)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false for non-live tabs', () => {
|
||||||
|
expect(shouldAutoFocus('overview', true)).toBe(false);
|
||||||
|
expect(shouldAutoFocus('files', true)).toBe(false);
|
||||||
|
expect(shouldAutoFocus('trace', true)).toBe(false);
|
||||||
|
expect(shouldAutoFocus('activity', true)).toBe(false);
|
||||||
|
});
|
||||||
|
it('false when no task is selected', () => {
|
||||||
|
expect(shouldAutoFocus('browser', false)).toBe(false);
|
||||||
|
expect(shouldAutoFocus('ssh', false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
ui/src/lib/live-workspace.ts
Normal file
10
ui/src/lib/live-workspace.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { DetailTabId } from './urlState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the live-workspace auto-focus should be active: only when a task is
|
||||||
|
* selected AND the active detail tab is a live view (the AI browser or SSH
|
||||||
|
* console), which benefit from the wider focused layout.
|
||||||
|
*/
|
||||||
|
export function shouldAutoFocus(detailTab: DetailTabId, hasTask: boolean): boolean {
|
||||||
|
return hasTask && (detailTab === 'browser' || detailTab === 'ssh');
|
||||||
|
}
|
||||||
@ -43,6 +43,8 @@ export function systemPrefersDark(): boolean {
|
|||||||
return typeof window !== 'undefined' && window.matchMedia(DARK_MQ).matches;
|
return typeof window !== 'undefined' && window.matchMedia(DARK_MQ).matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const THEME_CHANGE_EVENT = 'maestro:theme-pref-changed';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a new preference and apply it to the document immediately. Used by
|
* Persist a new preference and apply it to the document immediately. Used by
|
||||||
* the theme toggle UI. The pure resolution lives in resolveTheme.
|
* the theme toggle UI. The pure resolution lives in resolveTheme.
|
||||||
@ -50,6 +52,14 @@ export function systemPrefersDark(): boolean {
|
|||||||
export function setThemePref(pref: ThemePref): void {
|
export function setThemePref(pref: ThemePref): void {
|
||||||
writeStoredTheme(pref);
|
writeStoredTheme(pref);
|
||||||
applyTheme(document.documentElement, resolveTheme(pref, systemPrefersDark()));
|
applyTheme(document.documentElement, resolveTheme(pref, systemPrefersDark()));
|
||||||
|
try {
|
||||||
|
// Carry the chosen pref in the event so listeners reflect it even if the
|
||||||
|
// localStorage write above failed (private mode) — re-reading storage
|
||||||
|
// would otherwise fall back to 'system' and desync the toggle.
|
||||||
|
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: pref }));
|
||||||
|
} catch {
|
||||||
|
/* non-browser context */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user