From fb61395adfea3538cc211f4e572d5f09dc0257ca Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:18:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E7=B5=82=E4=BA=86?= =?UTF-8?q?=E5=BE=8C=E3=82=82output=E3=82=92=E3=82=AD=E3=83=A3=E3=83=97?= =?UTF-8?q?=E3=83=81=E3=83=A3=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/exec.tsx | 1 + app/terminal/repl.tsx | 24 +++++++++++++++++++----- app/terminal/worker/jsEval.worker.ts | 4 ---- app/terminal/worker/pyodide.worker.ts | 4 ---- app/terminal/worker/ruby.worker.ts | 4 ---- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index fd7110e..46df421 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -63,6 +63,7 @@ export function ExecFile(props: ExecProps) { null, // ファイル実行で"return"メッセージが返ってくることはないはずなので、Prismを渡す必要はない props.language ); + // TODO: 実行が完了したあとに出力された場合、embedContextのsetExecResultにも出力を追加する必要があるが、それに対応したAPIになっていない }); // TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる setExecResult(props.filenames.join(","), outputs); diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx index d34bcf0..5b839de 100644 --- a/app/terminal/repl.tsx +++ b/app/terminal/repl.tsx @@ -130,7 +130,7 @@ export function ReplTerminal({ // inputBufferを更新し、画面に描画する const updateBuffer = useCallback( - (newBuffer: () => string[]) => { + (newBuffer: (() => string[]) | null, insertBefore?: () => void) => { if (terminalInstanceRef.current) { hideCursor(terminalInstanceRef.current); // バッファの行数分カーソルを戻す @@ -142,8 +142,12 @@ export function ReplTerminal({ terminalInstanceRef.current.write("\r"); // バッファの内容をクリア terminalInstanceRef.current.write("\x1b[0J"); - // 新しいバッファの内容を表示 - inputBuffer.current = newBuffer(); + // バッファの前に追加で出力する内容(前のコマンドの出力)があればここで書き込む + insertBefore?.(); + // 新しいバッファの内容を表示、nullなら現状維持 + if (newBuffer) { + inputBuffer.current = newBuffer(); + } for (let i = 0; i < inputBuffer.current.length; i++) { terminalInstanceRef.current.write( (i === 0 ? prompt : (promptMore ?? prompt)) ?? "> " @@ -214,12 +218,22 @@ export function ReplTerminal({ const command = inputBuffer.current.join("\n").trim(); inputBuffer.current = []; const collectedOutputs: ReplOutput[] = []; + let executionDone = false; await runtimeMutex.runExclusive(async () => { await runCommand(command, (output) => { - collectedOutputs.push(output); - handleOutput(output); + if (executionDone) { + // すでに完了していて次のコマンドのプロンプトが出ている場合、その前に挿入 + updateBuffer(null, () => { + handleOutput(output); + }); + // TODO: embedContextのaddReplOutputにも出力を追加する必要があるが、それに対応したAPIになっていない + } else { + collectedOutputs.push(output); + handleOutput(output); + } }); }); + executionDone = true; updateBuffer(() => [""]); addReplOutput?.(terminalId, command, collectedOutputs); } diff --git a/app/terminal/worker/jsEval.worker.ts b/app/terminal/worker/jsEval.worker.ts index 64e2302..2f777ea 100644 --- a/app/terminal/worker/jsEval.worker.ts +++ b/app/terminal/worker/jsEval.worker.ts @@ -96,8 +96,6 @@ async function runCode( message: `${String(e)}`, }); } - } finally { - currentOutputCallback = null; } return { updatedFiles: {} as Record }; @@ -126,8 +124,6 @@ function runFile( message: `${String(e)}`, }); } - } finally { - currentOutputCallback = null; } return { updatedFiles: {} as Record }; diff --git a/app/terminal/worker/pyodide.worker.ts b/app/terminal/worker/pyodide.worker.ts index fbfbe7e..9464b0c 100644 --- a/app/terminal/worker/pyodide.worker.ts +++ b/app/terminal/worker/pyodide.worker.ts @@ -107,8 +107,6 @@ async function runCode( message: `予期せぬエラー: ${String(e).trim()}`, }); } - } finally { - currentOutputCallback = null; } const updatedFiles = readAllFiles(); @@ -165,8 +163,6 @@ async function runFile( message: `予期せぬエラー: ${String(e).trim()}`, }); } - } finally { - currentOutputCallback = null; } const updatedFiles = readAllFiles(); diff --git a/app/terminal/worker/ruby.worker.ts b/app/terminal/worker/ruby.worker.ts index af6779d..121a2ed 100644 --- a/app/terminal/worker/ruby.worker.ts +++ b/app/terminal/worker/ruby.worker.ts @@ -137,8 +137,6 @@ async function runCode( type: "error", message: formatRubyError(e, false), }); - } finally { - currentOutputCallback = null; } const updatedFiles = readAllFiles(); @@ -189,8 +187,6 @@ async function runFile( type: "error", message: formatRubyError(e, true), }); - } finally { - currentOutputCallback = null; } const updatedFiles = readAllFiles(); From 01b44fb413e3dda6dcd571e035c24b9968dfe9b0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:12:37 +0900 Subject: [PATCH 2/3] Refactor terminal API to support incremental output streaming (#162) * Initial plan * Update terminal API to support async/incremental output processing Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> * Address code review: Use crypto.randomUUID() and properly lookup commands by ID Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> * Add fallback for crypto.randomUUID() for better compatibility Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> * Use simple sequential counter for commandId per terminalId Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> --- app/terminal/embedContext.tsx | 72 ++++++++++++++++++++++++++++++----- app/terminal/exec.tsx | 14 +++---- app/terminal/repl.tsx | 10 ++--- 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/app/terminal/embedContext.tsx b/app/terminal/embedContext.tsx index 53138d3..15196c6 100644 --- a/app/terminal/embedContext.tsx +++ b/app/terminal/embedContext.tsx @@ -32,14 +32,16 @@ interface IEmbedContext { ) => Promise>>; replOutputs: Readonly>; + addReplCommand: (terminalId: TerminalId, command: string) => string; addReplOutput: ( terminalId: TerminalId, - command: string, - output: ReplOutput[] + commandId: string, + output: ReplOutput ) => void; execResults: Readonly>; - setExecResult: (filename: Filename, output: ReplOutput[]) => void; + clearExecResult: (filename: Filename) => void; + addExecOutput: (filename: Filename, output: ReplOutput) => void; } const EmbedContext = createContext(null!); @@ -63,6 +65,9 @@ export function EmbedContextProvider({ children }: { children: ReactNode }) { const [replOutputs, setReplOutputs] = useState< Record >({}); + const [commandIdCounters, setCommandIdCounters] = useState< + Record + >({}); const [execResults, setExecResults] = useState< Record >({}); @@ -71,6 +76,7 @@ export function EmbedContextProvider({ children }: { children: ReactNode }) { if (pathname && pathname !== currentPathname) { setCurrentPathname(pathname); setReplOutputs({}); + setCommandIdCounters({}); setExecResults({}); } }, [pathname, currentPathname]); @@ -100,8 +106,16 @@ export function EmbedContextProvider({ children }: { children: ReactNode }) { }, [pathname] ); - const addReplOutput = useCallback( - (terminalId: TerminalId, command: string, output: ReplOutput[]) => + const addReplCommand = useCallback( + (terminalId: TerminalId, command: string): string => { + let commandId = ""; + setCommandIdCounters((counters) => { + const newCounters = { ...counters }; + const currentCount = newCounters[terminalId] ?? 0; + commandId = String(currentCount); + newCounters[terminalId] = currentCount + 1; + return newCounters; + }); setReplOutputs((outs) => { outs = { ...outs }; if (!(terminalId in outs)) { @@ -109,17 +123,53 @@ export function EmbedContextProvider({ children }: { children: ReactNode }) { } outs[terminalId] = [ ...outs[terminalId], - { command: command, output: output }, + { command: command, output: [], commandId }, ]; return outs; + }); + return commandId; + }, + [] + ); + const addReplOutput = useCallback( + (terminalId: TerminalId, commandId: string, output: ReplOutput) => + setReplOutputs((outs) => { + outs = { ...outs }; + if (terminalId in outs) { + outs[terminalId] = [...outs[terminalId]]; + // Find the command by commandId + const commandIndex = outs[terminalId].findIndex( + (cmd) => cmd.commandId === commandId + ); + if (commandIndex >= 0) { + const command = outs[terminalId][commandIndex]; + outs[terminalId][commandIndex] = { + ...command, + output: [...command.output, output], + }; + } + } + return outs; + }), + [] + ); + const clearExecResult = useCallback( + (filename: Filename) => + setExecResults((results) => { + results = { ...results }; + results[filename] = []; + return results; }), [] ); - const setExecResult = useCallback( - (filename: Filename, output: ReplOutput[]) => + const addExecOutput = useCallback( + (filename: Filename, output: ReplOutput) => setExecResults((results) => { results = { ...results }; - results[filename] = output; + if (!(filename in results)) { + results[filename] = []; + } + results[filename] = [...results[filename], output]; return results; }), [] @@ -131,9 +181,11 @@ export function EmbedContextProvider({ children }: { children: ReactNode }) { files: files[pathname] || {}, writeFile, replOutputs, + addReplCommand, addReplOutput, execResults, - setExecResult, + clearExecResult, + addExecOutput, }} > {children} diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index 46df421..566d1f0 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -32,7 +32,7 @@ export function ExecFile(props: ExecProps) { } }, }); - const { files, setExecResult } = useEmbedContext(); + const { files, clearExecResult, addExecOutput } = useEmbedContext(); const { ready, runFiles, getCommandlineStr } = useRuntime(props.language); @@ -46,10 +46,12 @@ export function ExecFile(props: ExecProps) { (async () => { clearTerminal(terminalInstanceRef.current!); terminalInstanceRef.current!.write(systemMessageColor("実行中です...")); - const outputs: ReplOutput[] = []; + // TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる + const filenameKey = props.filenames.join(","); + clearExecResult(filenameKey); let isFirstOutput = true; await runFiles(props.filenames, files, (output) => { - outputs.push(output); + addExecOutput(filenameKey, output); if (isFirstOutput) { // Clear "実行中です..." message only on first output clearTerminal(terminalInstanceRef.current!); @@ -63,10 +65,7 @@ export function ExecFile(props: ExecProps) { null, // ファイル実行で"return"メッセージが返ってくることはないはずなので、Prismを渡す必要はない props.language ); - // TODO: 実行が完了したあとに出力された場合、embedContextのsetExecResultにも出力を追加する必要があるが、それに対応したAPIになっていない }); - // TODO: 1つのファイル名しか受け付けないところに無理やりコンマ区切りで全部のファイル名を突っ込んでいる - setExecResult(props.filenames.join(","), outputs); setExecutionState("idle"); })(); } @@ -75,7 +74,8 @@ export function ExecFile(props: ExecProps) { ready, props.filenames, runFiles, - setExecResult, + clearExecResult, + addExecOutput, terminalInstanceRef, props.language, files, diff --git a/app/terminal/repl.tsx b/app/terminal/repl.tsx index 5b839de..6c5eca7 100644 --- a/app/terminal/repl.tsx +++ b/app/terminal/repl.tsx @@ -31,6 +31,7 @@ export interface ReplOutput { export interface ReplCommand { command: string; output: ReplOutput[]; + commandId?: string; // Optional for backward compatibility } export type SyntaxStatus = "complete" | "incomplete" | "invalid"; // 構文チェックの結果 @@ -80,7 +81,7 @@ export function ReplTerminal({ language, initContent, }: ReplComponentProps) { - const { addReplOutput } = useEmbedContext(); + const { addReplCommand, addReplOutput } = useEmbedContext(); const [Prism, setPrism] = useState(null); useEffect(() => { @@ -217,7 +218,7 @@ export function ReplTerminal({ terminalInstanceRef.current.writeln(""); const command = inputBuffer.current.join("\n").trim(); inputBuffer.current = []; - const collectedOutputs: ReplOutput[] = []; + const commandId = addReplCommand(terminalId, command); let executionDone = false; await runtimeMutex.runExclusive(async () => { await runCommand(command, (output) => { @@ -226,16 +227,14 @@ export function ReplTerminal({ updateBuffer(null, () => { handleOutput(output); }); - // TODO: embedContextのaddReplOutputにも出力を追加する必要があるが、それに対応したAPIになっていない } else { - collectedOutputs.push(output); handleOutput(output); } + addReplOutput(terminalId, commandId, output); }); }); executionDone = true; updateBuffer(() => [""]); - addReplOutput?.(terminalId, command, collectedOutputs); } } else if (code === 127) { // Backspace @@ -279,6 +278,7 @@ export function ReplTerminal({ runCommand, handleOutput, tabSize, + addReplCommand, addReplOutput, terminalId, terminalInstanceRef, From 442d856c7512660b961a51d801524bef489e57b5 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:14:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?lint=E3=82=A8=E3=83=A9=E3=83=BC=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/terminal/embedContext.tsx | 1 + app/terminal/exec.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/terminal/embedContext.tsx b/app/terminal/embedContext.tsx index 15196c6..bc447f3 100644 --- a/app/terminal/embedContext.tsx +++ b/app/terminal/embedContext.tsx @@ -65,6 +65,7 @@ export function EmbedContextProvider({ children }: { children: ReactNode }) { const [replOutputs, setReplOutputs] = useState< Record >({}); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [commandIdCounters, setCommandIdCounters] = useState< Record >({}); diff --git a/app/terminal/exec.tsx b/app/terminal/exec.tsx index 566d1f0..e757319 100644 --- a/app/terminal/exec.tsx +++ b/app/terminal/exec.tsx @@ -7,7 +7,7 @@ import { systemMessageColor, useTerminal, } from "./terminal"; -import { writeOutput, ReplOutput } from "./repl"; +import { writeOutput } from "./repl"; import { useEffect, useState } from "react"; import { useEmbedContext } from "./embedContext"; import { RuntimeLang, useRuntime } from "./runtime";