Skip to content

Instantly share code, notes, and snippets.

@nakasyou
Created July 30, 2026 12:35
Show Gist options
  • Select an option

  • Save nakasyou/2b5a8aabc5a974ae89120a343cb3951f to your computer and use it in GitHub Desktop.

Select an option

Save nakasyou/2b5a8aabc5a974ae89120a343cb3951f to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name ChatGPT Continue Mode
// @namespace https://chatgpt.com/
// @version 1.1.0
// @description /continue on と /continue off で自動継続する
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
;(function () {
"use strict"
const CONTROLLER_KEY = "__chatgptContinueModeController"
// スクリプト更新・再実行時に古いインスタンスを破棄する。
window[CONTROLLER_KEY]?.destroy?.()
let enabled = false
let destroyed = false
let processingCommand = false
let sending = false
/*
* continue. を送信したあと、応答開始を待っている状態。
*/
let waitingForResponseStart = false
/*
* stop-button を一度確認済みか。
*/
let responseObserved = false
/*
* 応答終了後の送信予定時刻。
*/
let sendAfter = null
let lastContinuationAt = 0
const sleep = milliseconds =>
new Promise(resolve => setTimeout(resolve, milliseconds))
function getEditor() {
const editor = document.querySelector("#prompt-textarea")
return editor instanceof HTMLElement
? editor
: null
}
function getEditorText() {
const editor = getEditor()
if (!editor) {
return ""
}
return editor.innerText
.replace(/\u00a0/g, " ")
.replace(/\u200b/g, "")
.trim()
}
function getStopButton() {
return document.querySelector(
[
'[data-testid="stop-button"]',
'button[aria-label*="Stop"]',
'button[aria-label*="停止"]',
].join(","),
)
}
function getSendButton() {
return document.querySelector(
[
'[data-testid="send-button"]',
'button[aria-label*="Send"]',
'button[aria-label*="送信"]',
].join(","),
)
}
function selectEditorContents(editor) {
editor.focus()
const selection = window.getSelection()
if (!selection) {
throw new Error("Selection API is unavailable")
}
const range = document.createRange()
range.selectNodeContents(editor)
selection.removeAllRanges()
selection.addRange(range)
}
function clearEditor(editor = getEditor()) {
if (!(editor instanceof HTMLElement)) {
return false
}
selectEditorContents(editor)
/*
* execCommand が利用できる場合は、ProseMirror の入力経路を通す。
*/
const deleted = document.execCommand(
"delete",
false,
)
/*
* 削除できなかった場合のフォールバック。
*/
if (getEditorText() !== "") {
editor.replaceChildren(document.createElement("p"))
editor.dispatchEvent(
new InputEvent("input", {
bubbles: true,
inputType: "deleteContentBackward",
}),
)
}
return deleted || getEditorText() === ""
}
function insertText(editor, text) {
if (!(editor instanceof HTMLElement)) {
throw new TypeError("editor must be an HTMLElement")
}
editor.focus()
const selection = window.getSelection()
if (!selection) {
throw new Error("Selection API is unavailable")
}
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
const inserted = document.execCommand(
"insertText",
false,
text,
)
/*
* execCommand が失敗した場合のフォールバック。
*/
if (!inserted || getEditorText() !== text) {
editor.replaceChildren()
const paragraph = document.createElement("p")
paragraph.textContent = text
editor.append(paragraph)
editor.dispatchEvent(
new InputEvent("input", {
bubbles: true,
inputType: "insertText",
data: text,
}),
)
}
return getEditorText() === text
}
function setEnabled(nextEnabled) {
enabled = nextEnabled
waitingForResponseStart = false
responseObserved = false
sendAfter = null
if (enabled) {
if (getStopButton()) {
/*
* 現在応答中なら、その終了を待つ。
*/
responseObserved = true
console.info(
"[continue mode] on; waiting for current response",
)
} else {
/*
* 応答終了後に ON にした場合でも、最初の continue を送る。
*/
sendAfter = Date.now() + 3000
console.info(
"[continue mode] on; first continuation scheduled",
)
}
} else {
console.info("[continue mode] off")
}
}
function normalizeCommand(text) {
return text
.toLowerCase()
.replace(/\s+/g, " ")
.trim()
}
function handleCommand() {
if (processingCommand) {
return false
}
const command = normalizeCommand(
getEditorText(),
)
if (
command !== "/continue on" &&
command !== "/continue off"
) {
return false
}
processingCommand = true
try {
clearEditor()
setEnabled(command === "/continue on")
return true
} finally {
queueMicrotask(() => {
processingCommand = false
})
}
}
function onKeyDown(event) {
if (
event.key !== "Enter" ||
event.shiftKey ||
event.ctrlKey ||
event.altKey ||
event.metaKey ||
event.isComposing
) {
return
}
if (!handleCommand()) {
return
}
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
}
function onClick(event) {
const target = event.target
if (!(target instanceof Element)) {
return
}
if (
!target.closest(
[
'[data-testid="send-button"]',
'button[aria-label*="Send"]',
'button[aria-label*="送信"]',
].join(","),
)
) {
return
}
if (!handleCommand()) {
return
}
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
}
async function sendContinue() {
if (
!enabled ||
destroyed ||
sending ||
getStopButton()
) {
return false
}
const editor = getEditor()
if (!editor) {
console.warn("[continue mode] editor not found")
return false
}
if (getEditorText() !== "") {
console.info(
"[continue mode] paused: editor is not empty",
)
return false
}
sending = true
try {
editor.focus()
/*
* ProseMirror にテキストを入力する。
*/
const inserted = document.execCommand(
"insertText",
false,
"continue.",
)
if (!inserted || getEditorText() !== "continue.") {
editor.replaceChildren()
const paragraph = document.createElement("p")
paragraph.textContent = "continue."
editor.append(paragraph)
}
/*
* React / ProseMirror に内容変更を認識させる。
*/
editor.dispatchEvent(
new InputEvent("beforeinput", {
bubbles: true,
cancelable: true,
inputType: "insertText",
data: "continue.",
}),
)
editor.dispatchEvent(
new InputEvent("input", {
bubbles: true,
composed: true,
inputType: "insertText",
data: "continue.",
}),
)
editor.dispatchEvent(
new Event("change", {
bubbles: true,
}),
)
await sleep(700)
if (!enabled || destroyed) {
clearEditor(editor)
return false
}
let submitted = false
/*
* 1. 現在有効な送信ボタンをクリックする。
*/
const sendButton = [
...document.querySelectorAll(
[
'[data-testid="send-button"]',
'button[aria-label*="Send"]',
'button[aria-label*="送信"]',
].join(","),
),
].find(button => {
return (
button instanceof HTMLButtonElement &&
!button.disabled &&
button.getAttribute("aria-disabled") !== "true" &&
button.offsetParent !== null
)
})
if (sendButton instanceof HTMLButtonElement) {
sendButton.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerType: "mouse",
}),
)
sendButton.dispatchEvent(
new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
view: window,
}),
)
sendButton.dispatchEvent(
new MouseEvent("mouseup", {
bubbles: true,
cancelable: true,
view: window,
}),
)
sendButton.click()
submitted = true
console.info(
"[continue mode] send button clicked",
)
}
await sleep(500)
/*
* 2. クリック後も入力欄が残っているならフォームを送信する。
*/
if (getEditorText() === "continue.") {
const form = editor.closest("form")
if (form instanceof HTMLFormElement) {
form.requestSubmit()
submitted = true
console.info(
"[continue mode] form.requestSubmit called",
)
}
}
await sleep(500)
/*
* 3. まだ残っている場合は Enter を発火する。
*/
if (getEditorText() === "continue.") {
editor.focus()
for (const type of [
"keydown",
"keypress",
"keyup",
]) {
editor.dispatchEvent(
new KeyboardEvent(type, {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
composed: true,
}),
)
}
submitted = true
console.info(
"[continue mode] Enter events dispatched",
)
}
await sleep(1000)
/*
* 入力欄が空になったか、生成が始まった場合だけ成功扱い。
*/
const actuallySubmitted =
getEditorText() === "" ||
Boolean(getStopButton())
if (!actuallySubmitted) {
console.warn(
"[continue mode] submission failed; text is still in editor",
)
return false
}
lastContinuationAt = Date.now()
waitingForResponseStart = true
responseObserved = Boolean(getStopButton())
sendAfter = null
console.info(
"[continue mode] continue submitted",
)
return submitted
} catch (error) {
console.error(
"[continue mode] send failed",
error,
)
return false
} finally {
sending = false
}
}
async function run() {
while (!destroyed) {
await sleep(250)
if (!enabled) {
continue
}
const stopButton = getStopButton()
if (stopButton) {
/*
* 応答開始または生成中を確認。
*/
responseObserved = true
waitingForResponseStart = false
sendAfter = null
continue
}
if (waitingForResponseStart) {
/*
* 送信直後、stop-button が出るまで待つ。
*/
if (
Date.now() - lastContinuationAt >
30_000
) {
waitingForResponseStart = false
/*
* 応答開始を観測できなかった場合、即座の無限再送は避ける。
*/
sendAfter = Date.now() + 5000
console.warn(
"[continue mode] response start not detected; retry scheduled",
)
}
continue
}
if (responseObserved) {
/*
* stop-button が消えたので応答終了。
*/
responseObserved = false
sendAfter = Date.now() + 3000
console.info(
"[continue mode] response finished; continuation scheduled",
)
continue
}
if (
sendAfter !== null &&
Date.now() >= sendAfter
) {
/*
* 送信に失敗したときも、高頻度で繰り返さない。
*/
sendAfter = Date.now() + 3000
await sendContinue()
}
}
}
document.addEventListener(
"keydown",
onKeyDown,
true,
)
document.addEventListener(
"click",
onClick,
true,
)
window[CONTROLLER_KEY] = {
enable() {
setEnabled(true)
},
disable() {
setEnabled(false)
},
get enabled() {
return enabled
},
async sendNow() {
return sendContinue()
},
destroy() {
if (destroyed) {
return
}
destroyed = true
enabled = false
document.removeEventListener(
"keydown",
onKeyDown,
true,
)
document.removeEventListener(
"click",
onClick,
true,
)
console.info(
"[continue mode] destroyed",
)
},
}
void run()
console.info(
"[continue mode] loaded",
)
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment