diff --git a/docs/tools/ssh-console-tools.md b/docs/tools/ssh-console-tools.md index c396e71..ad641c2 100644 --- a/docs/tools/ssh-console-tools.md +++ b/docs/tools/ssh-console-tools.md @@ -127,6 +127,46 @@ Return (kind=scrollback): 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 で状態確認 + ## エラー時のリカバリ | エラー | 対応 | diff --git a/pieces/ssh-console.yaml b/pieces/ssh-console.yaml index 2033d97..47e4ade 100644 --- a/pieces/ssh-console.yaml +++ b/pieces/ssh-console.yaml @@ -70,7 +70,7 @@ movements: - 完了: complete({status: "success", result: "..."}) - 中断: complete({status: "aborted", abort_reason: "..."}) - 確認待ち: 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: ['*'] default_next: COMPLETE rules: [] diff --git a/src/engine/agent-loop.ts b/src/engine/agent-loop.ts index 10aa69b..b08c1d6 100644 --- a/src/engine/agent-loop.ts +++ b/src/engine/agent-loop.ts @@ -193,7 +193,8 @@ function appendConsoleScreenIfAny( if (!_activeSessionLookup || taskId === undefined || taskId === null) return prompt; const allowsConsole = movement.allowedTools.includes('SshConsoleSend') || - movement.allowedTools.includes('SshConsoleSnapshot'); + movement.allowedTools.includes('SshConsoleSnapshot') || + movement.allowedTools.includes('SshConsoleRun'); if (!allowsConsole) return prompt; const session = _activeSessionLookup(String(taskId)); if (!session) return prompt; diff --git a/src/engine/piece-runner.ts b/src/engine/piece-runner.ts index c61b9d2..3eb4b16 100644 --- a/src/engine/piece-runner.ts +++ b/src/engine/piece-runner.ts @@ -149,7 +149,7 @@ export function validatePieceDef(piece: PieceDef): void { */ const SSH_TOOL_NAMES: ReadonlySet = new Set([ 'SshExec', 'SshUpload', 'SshDownload', 'SshListConnections', - 'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', + 'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', 'SshConsoleRun', ]); /** diff --git a/src/engine/tools/console-run-lib.test.ts b/src/engine/tools/console-run-lib.test.ts new file mode 100644 index 0000000..af811f5 --- /dev/null +++ b/src/engine/tools/console-run-lib.test.ts @@ -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 + }); +}); diff --git a/src/engine/tools/console-run-lib.ts b/src/engine/tools/console-run-lib.ts new file mode 100644 index 0000000..9bb5079 --- /dev/null +++ b/src/engine/tools/console-run-lib.ts @@ -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 +} diff --git a/src/engine/tools/docs.ts b/src/engine/tools/docs.ts index 2e7810d..47c5cee 100644 --- a/src/engine/tools/docs.ts +++ b/src/engine/tools/docs.ts @@ -75,6 +75,7 @@ const TOOL_DOC_ALIASES: Record = { sshconsoleensure: 'ssh-console-tools', sshconsolesend: 'ssh-console-tools', sshconsolesnapshot: 'ssh-console-tools', + sshconsolerun: 'ssh-console-tools', // slide.ts をまとめる settheme: 'slide', addslide: 'slide', diff --git a/src/engine/tools/ssh-console.test.ts b/src/engine/tools/ssh-console.test.ts index 7a41b2b..9503418 100644 --- a/src/engine/tools/ssh-console.test.ts +++ b/src/engine/tools/ssh-console.test.ts @@ -273,12 +273,16 @@ describe('SshConsoleSend', () => { it('writes input to session and returns screen snapshot', async () => { const { sub, registry } = mkStubSubsystem(); const writes: Buffer[] = []; + const now = Date.now(); const fakeSession = { localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, + idleMs: () => 10_000, + lastAiInputAt: now - 10_000, + startedAt: now - 20_000, write: (b: Buffer) => writes.push(b), snapshotScreen: () => ({ cols: 80, @@ -306,9 +310,13 @@ describe('SshConsoleSend', () => { it('auto-appends \\n when input is printable without line terminator', async () => { const { sub, registry, connectionRepo } = mkStubSubsystem(); connectionRepo.resolveConnection.mockReturnValue(mkConn()); + const now = Date.now(); const fakeSession = { localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, + idleMs: () => 10_000, + lastAiInputAt: now - 10_000, + startedAt: now - 20_000, write: vi.fn(), snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ', cursor: { x: 0, y: 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 () => { const { sub, registry, connectionRepo } = mkStubSubsystem(); connectionRepo.resolveConnection.mockReturnValue(mkConn()); + const now = Date.now(); const fakeSession = { 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 } }), totalOutputBytes: 0, }; @@ -411,11 +425,15 @@ describe('SshConsoleSend', () => { const { sub, registry, connectionRepo } = mkStubSubsystem(); connectionRepo.resolveConnection.mockReturnValue(mkConn()); const writes: Buffer[] = []; + const now = Date.now(); registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, + idleMs: () => 10_000, + lastAiInputAt: now - 10_000, + startedAt: now - 20_000, write: (b: Buffer) => writes.push(b), snapshotScreen: () => ({ cols: 80, rows: 24, text: 'ok', cursor: { x: 0, y: 0 } }), totalOutputBytes: 0, @@ -456,6 +474,136 @@ describe('SshConsoleSend', () => { expect(res?.isError).toBe(true); 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', () => { @@ -554,3 +702,146 @@ describe('SshConsoleSnapshot', () => { 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); + }); +}); diff --git a/src/engine/tools/ssh-console.ts b/src/engine/tools/ssh-console.ts index 140401a..e7abf57 100644 --- a/src/engine/tools/ssh-console.ts +++ b/src/engine/tools/ssh-console.ts @@ -30,6 +30,7 @@ import { checkConsoleInput } from '../../ssh/console-deny-check.js'; import { clearBuffer } from '../../ssh/crypto.js'; import { logger } from '../../logger.js'; import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js'; +import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js'; // ────────────────────────────────────────────────────────────────────── // Tool definitions @@ -62,13 +63,17 @@ export const TOOL_DEFS: Record = { function: { name: 'SshConsoleSend', 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: { type: 'object', properties: { connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。明示する場合は active session の id と一致する必要あり (mismatch はエラー)。' }, input: { type: 'string' }, wait_ms: { type: 'number' }, + confirm_interrupt: { + type: 'boolean', + description: 'Ctrl-C (\\x03) を含む入力が interrupt_blocked=true で返った場合、true を付けて再送すると強制送信する。', + }, }, required: ['input'], }, @@ -91,12 +96,31 @@ export const TOOL_DEFS: Record = { }, }, }, + 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([ 'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', + 'SshConsoleRun', ]); // ────────────────────────────────────────────────────────────────────── @@ -516,6 +540,29 @@ async function sendInput( // Record bytes_before so we can compute new_output_bytes after wait. 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 // bytes straight to the PTY so the shell echoes them back the same // way it does for human keystrokes — partial input is allowed and @@ -565,6 +612,8 @@ async function sendInput( cursor: screen.cursor, cols: screen.cols, rows: screen.rows, + idle_ms: session.idleMs(), + maybe_waiting_for_input: detectWaitingForInput(screen.text), ...(autoNewlineAppended ? { auto_newline_appended: true } : {}), }, 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, + ctx: ToolContext, + sub: SshSubsystem, +): Promise { + 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((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 // ────────────────────────────────────────────────────────────────────── @@ -713,5 +966,6 @@ export async function executeTool( if (name === 'SshConsoleEnsure') return ensureTool(input, ctx, subsystem); if (name === 'SshConsoleSend') return sendInput(input, ctx, subsystem); if (name === 'SshConsoleSnapshot') return snapshot(input, ctx, subsystem); + if (name === 'SshConsoleRun') return run(input, ctx, subsystem); return null; } diff --git a/src/metrics/tool-name-allowlist.ts b/src/metrics/tool-name-allowlist.ts index f4c2756..dbbd32b 100644 --- a/src/metrics/tool-name-allowlist.ts +++ b/src/metrics/tool-name-allowlist.ts @@ -83,7 +83,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray = [ // ssh.ts 'SshDownload', 'SshExec', 'SshListConnections', 'SshUpload', // ssh-console.ts - 'SshConsoleEnsure', 'SshConsoleSendKeys', 'SshConsoleSnapshot', + 'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', 'SshConsoleRun', // notes.ts 'ReadNote', 'SearchNotes', 'WriteNote', // dashboard.ts diff --git a/src/ssh/console-session.test.ts b/src/ssh/console-session.test.ts index 1c5f8bb..57a9ede 100644 --- a/src/ssh/console-session.test.ts +++ b/src/ssh/console-session.test.ts @@ -164,6 +164,22 @@ describe('ConsoleSession', () => { 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 () => { const ch = new StubChannel(); const { session, audit, client } = mkSession(ch); diff --git a/src/ssh/console-session.ts b/src/ssh/console-session.ts index c09ef95..5797b9e 100644 --- a/src/ssh/console-session.ts +++ b/src/ssh/console-session.ts @@ -121,6 +121,9 @@ export class ConsoleSession { private readonly auditRepo: SshAuditRepo; private _lastActivityAt: number; + private _lastOutputAt: number; + private _lastAiInputAt = 0; + private _lastHumanInputAt = 0; private _totalInputBytes = 0; private _totalOutputBytes = 0; @@ -136,6 +139,7 @@ export class ConsoleSession { this.startedByUserId = args.startedByUserId; this.startedAt = Date.now(); this._lastActivityAt = this.startedAt; + this._lastOutputAt = this.startedAt; this.cols = args.cols; this.rows = args.rows; this.channel = args.channel; @@ -181,6 +185,18 @@ export class ConsoleSession { get lastActivityAt(): number { 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 { return this._totalInputBytes; } @@ -239,6 +255,8 @@ export class ConsoleSession { // Partial input (no newline) is forwarded so the shell can echo each // character back, matching the live terminal experience the user // expects in either role. + if (source === 'ai') this._lastAiInputAt = Date.now(); + else this._lastHumanInputAt = Date.now(); const out = source === 'ai' ? normalizeLfToCr(buf) : buf; this._totalInputBytes += out.length; const ok = this.channel.write(out); @@ -338,6 +356,7 @@ export class ConsoleSession { private handleOutput(data: Buffer): void { this._totalOutputBytes += data.length; this._lastActivityAt = Date.now(); + this._lastOutputAt = Date.now(); this.scrollback.append(data); this.writeToHeadlessSync(data); for (const l of this.outputListeners) { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index cce0913..e4a6fda 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 { LocalTask, type Visibility } from './api'; import { useUrlState } from './hooks/useUrlState'; @@ -41,6 +41,10 @@ import { UserFolderTab } from './components/userfolder/UserFolderTab'; import { HelpPage } from './pages/HelpPage'; import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel'; 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 { id: string; @@ -135,6 +139,23 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable 'ui.focusedChatPx', 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 [navDrawerOpen, setNavDrawerOpen] = useState(false); const hamburgerRef = useRef(null); @@ -148,7 +169,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable // Shared navigation handler used by both TopBar and NavDrawer. // Guards against discarding unsaved edits before switching pages. - const handleNavigatePage = (p: PageId) => { + const handleNavigatePage = useCallback((p: PageId) => { if (p === page) { setNavDrawerOpen(false); return; @@ -156,7 +177,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable if (!confirmDiscardUnsaved()) return; setUrlState(prev => ({ ...prev, page: p })); setNavDrawerOpen(false); - }; + }, [page, setUrlState]); const edgeSwipe = useEdgeSwipe({ enabled: compactMode && !navDrawerOpen, @@ -189,6 +210,38 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable // its own when mounted. 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 が管理 const [notifyEnabled] = useLocalStorageState('notify.enabled', true); const [notifyEvents] = useLocalStorageState( @@ -315,10 +368,14 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable pathSegments: fileBrowser.pathSegments, loading: localTaskQuery.isLoading, detailTab: overrides?.detailTab ?? detailTab, - detailWidth, + detailWidth: (isFocused ? 'focused' : 'normal') as 'normal' | 'focused', showWidthToggle: overrides?.showWidthToggle ?? true, 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' }))), onDelete: handleDelete, onSectionChange: fileBrowser.setSection, @@ -335,7 +392,6 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable // Layout calculation const sidebarWidth = 'clamp(240px, 22vw, 280px)'; const detailPanelWidth = 'clamp(280px, 26vw, 440px)'; // normal mode 時のみ使用 - const isFocused = detailWidth === 'focused'; const RAIL_PX = 48; const HANDLE_PX = 4; const MIN_CHAT_PX = 280; @@ -384,6 +440,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable onOpenDrawer={openNavDrawer} hamburgerButtonRef={hamburgerRef} navDrawerOpen={navDrawerOpen} + onOpenCommandK={() => setCmdkOpen(true)} />
@@ -521,7 +578,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable ? setDetailWidth('normal')} + onExitFocused={() => { setOverride({ key: overrideKey, value: false }); setDetailWidth('normal'); }} /> :
} @@ -616,6 +673,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable logoUrl={branding.logoUrl} returnFocusRef={hamburgerRef} /> + setCmdkOpen(false)} ctx={cmdkCtx} />
); } diff --git a/ui/src/components/command/CommandPalette.tsx b/ui/src/components/command/CommandPalette.tsx new file mode 100644 index 0000000..6b240f3 --- /dev/null +++ b/ui/src/components/command/CommandPalette.tsx @@ -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(null); + const inputRef = useRef(null); + const openerRef = useRef(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) => { + 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 ( + +
+ setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder="コマンド・タスクを検索…" + className="w-full h-9 px-2 bg-transparent outline-none text-sm" + /> +
+
+ {flat.length === 0 &&
該当なし
} + {groups.map((g) => ( +
+
{g.label}
+ {g.items.map((item: CommandItem) => { + const active = item.id === highlightedId; + return ( +
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' + }`} + > + {item.label} + {item.hint && {item.hint}} +
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/ui/src/components/layout/ThemeToggle.tsx b/ui/src/components/layout/ThemeToggle.tsx index 278fa81..a1df95a 100644 --- a/ui/src/components/layout/ThemeToggle.tsx +++ b/ui/src/components/layout/ThemeToggle.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { type ThemePref, readStoredTheme, setThemePref } from '../../lib/theme'; +import { useState, useEffect } from 'react'; +import { type ThemePref, isThemePref, readStoredTheme, setThemePref, THEME_CHANGE_EVENT } from '../../lib/theme'; const ICON_PROPS = { width: 14, @@ -56,6 +56,17 @@ const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [ export function ThemeToggle() { const [pref, setPref] = useState(() => 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).detail; + setPref(isThemePref(detail) ? detail : readStoredTheme()); + }; + window.addEventListener(THEME_CHANGE_EVENT, sync); + return () => window.removeEventListener(THEME_CHANGE_EVENT, sync); + }, []); + const choose = (value: ThemePref) => { setPref(value); setThemePref(value); diff --git a/ui/src/components/layout/TopBar.tsx b/ui/src/components/layout/TopBar.tsx index 1aa92c1..a353bd2 100644 --- a/ui/src/components/layout/TopBar.tsx +++ b/ui/src/components/layout/TopBar.tsx @@ -14,6 +14,7 @@ interface TopBarProps { onOpenDrawer: () => void; hamburgerButtonRef?: React.RefObject; navDrawerOpen?: boolean; + onOpenCommandK?: () => void; } export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [ @@ -69,6 +70,7 @@ export function TopBar({ onOpenDrawer, hamburgerButtonRef, navDrawerOpen = false, + onOpenCommandK, }: TopBarProps) { const visibleNav = visibleNavItemsFor(isAdmin, authEnabled); const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length)); @@ -138,6 +140,17 @@ export function TopBar({
+ {onOpenCommandK && ( + + )} {user && (
diff --git a/ui/src/lib/command-palette.test.ts b/ui/src/lib/command-palette.test.ts new file mode 100644 index 0000000..3f398af --- /dev/null +++ b/ui/src/lib/command-palette.test.ts @@ -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 { + 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); + }); +}); diff --git a/ui/src/lib/command-palette.ts b/ui/src/lib/command-palette.ts new file mode 100644 index 0000000..97878e1 --- /dev/null +++ b/ui/src/lib/command-palette.ts @@ -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; +} diff --git a/ui/src/lib/live-workspace.test.ts b/ui/src/lib/live-workspace.test.ts new file mode 100644 index 0000000..dc93190 --- /dev/null +++ b/ui/src/lib/live-workspace.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/live-workspace.ts b/ui/src/lib/live-workspace.ts new file mode 100644 index 0000000..2d1a48c --- /dev/null +++ b/ui/src/lib/live-workspace.ts @@ -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'); +} diff --git a/ui/src/lib/theme.ts b/ui/src/lib/theme.ts index 3a195e9..5f2977d 100644 --- a/ui/src/lib/theme.ts +++ b/ui/src/lib/theme.ts @@ -43,6 +43,8 @@ export function systemPrefersDark(): boolean { 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 * the theme toggle UI. The pure resolution lives in resolveTheme. @@ -50,6 +52,14 @@ export function systemPrefersDark(): boolean { export function setThemePref(pref: ThemePref): void { writeStoredTheme(pref); 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 */ + } } /**