feat: Set terminal window title when running applications (#8492)

Set window title to 'sbt <command>: <org> % <name> % <version>' when
running sbt run, runMain, bgRun, or bgRunMain.

For server-side runs, window title is set directly. For client-side runs (sbtn), window title is passed via RunInfo protocol
and set by NetworkClient.

Fixes #7586
This commit is contained in:
MkDev11 2026-01-12 02:32:52 -05:00 committed by GitHub
parent 0ec500392f
commit add43bd230
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 93 additions and 17 deletions

View File

@ -568,6 +568,8 @@ lazy val protocolProj = (project in file("protocol"))
contrabandSettings,
mimaSettings,
mimaBinaryIssueFilters ++= Seq(
exclude[DirectMissingMethodProblem]("sbt.internal.worker.RunInfo.apply"),
exclude[IncompatibleMethTypeProblem]("sbt.internal.worker.RunInfo.apply"),
)
)

View File

@ -741,7 +741,13 @@ class NetworkClient(
case _ => Failure(new MessageOnlyException(s"runInfo is not specified in $params"))
}
private def setWindowTitle(title: String): Unit =
if System.console() != null && System.getenv("TERM") != null then
Console.print(s"\u001b]0;$title\u0007")
Console.flush()
private def clientSideRun(runInfo: RunInfo): Try[Unit] = {
runInfo.windowTitle.foreach(setWindowTitle)
def jvmRun(info: JvmRunInfo): Try[Unit] = {
val option = ForkOptions(
javaHome = info.javaHome.map(new File(_)),

View File

@ -15,6 +15,19 @@ import sbt.util.CacheImplicits.given
import xsbti.FileConverter
object RunUtil:
private def setWindowTitle(title: String): Unit =
if System.console() != null && System.getenv("TERM") != null then
scala.Console.print(s"\u001b]0;$title\u0007")
scala.Console.flush()
private[sbt] def mkWindowTitle(
command: String,
org: String,
name: String,
version: String
): String =
s"sbt $command: $org % $name % $version"
/**
* Conventional server-side run implementation.
*/
@ -86,10 +99,10 @@ object RunUtil:
mainClass: String,
cp: Classpath,
fo: ForkOptions,
conv: FileConverter
conv: FileConverter,
windowTitle: Option[String] = None
): RunInfo =
val strategy = fo.outputStrategy.map(_.getClass().getSimpleName().filter(_ != '$'))
// sbtn doesn't set java.home, so we need to do the fallback here
val javaHome =
fo.javaHome.map(IO.toURI).orElse(sys.props.get("java.home").map(x => IO.toURI(new File(x))))
val jvmRunInfo = JvmRunInfo(
@ -105,7 +118,9 @@ object RunUtil:
)
RunInfo(
jvm = true,
jvmRunInfo = jvmRunInfo,
jvmRunInfo = Some(jvmRunInfo),
nativeRunInfo = None,
windowTitle = windowTitle,
)
def defaultRunMainTask(
@ -125,6 +140,7 @@ object RunUtil:
val hashClasspath = (bgRunMain / bgHashClasspath).value
val fo = (run / forkOptions).value
val state = Keys.state.value
val windowTitle = mkWindowTitle("runMain", organization.value, name.value, version.value)
if clientRun.value && state.isNetworkCommand then
val workingDir = service.createWorkingDirectory
val cp = service.copyClasspath(
@ -133,7 +149,7 @@ object RunUtil:
workingDir,
conv,
)
val info = mkRunInfo(args.toVector, mainClass, cp, fo, conv)
val info = mkRunInfo(args.toVector, mainClass, cp, fo, conv, Some(windowTitle))
val result = ClientJobParams(
runInfo = info
)
@ -141,6 +157,7 @@ object RunUtil:
state.notifyEvent(Serialization.clientJob, result)
result
else
setWindowTitle(windowTitle)
val wrapper = termWrapper(canonicalInput.value, echoInput.value)
val handle = service.runInBackgroundWithLoader(Keys.resolvedScoped.value, state):
(logger, workingDir) =>
@ -181,6 +198,7 @@ object RunUtil:
val hashClasspath = (bgRun / bgHashClasspath).value
val fo = (run / forkOptions).value
val state = Keys.state.value
val windowTitle = mkWindowTitle("run", organization.value, name.value, version.value)
if clientRun.value && state.isNetworkCommand then
val workingDir = service.createWorkingDirectory
val cp = service.copyClasspath(
@ -189,7 +207,7 @@ object RunUtil:
workingDir,
conv,
)
val info = mkRunInfo(args.toVector, mainClass, cp, fo, conv)
val info = mkRunInfo(args.toVector, mainClass, cp, fo, conv, Some(windowTitle))
val result = ClientJobParams(
runInfo = info
)
@ -197,6 +215,7 @@ object RunUtil:
state.notifyEvent(Serialization.clientJob, result)
result
else
setWindowTitle(windowTitle)
val wrapper = termWrapper(canonicalInput.value, echoInput.value)
val handle = service.runInBackgroundWithLoader(Keys.resolvedScoped.value, state):
(logger, workingDir) =>
@ -235,6 +254,7 @@ object RunUtil:
val hashClasspath = (bgRunMain / bgHashClasspath).value
val wrapper = termWrapper(canonicalInput.value, echoInput.value)
val converter = fileConverter.value
setWindowTitle(mkWindowTitle("bgRunMain", organization.value, name.value, version.value))
service.runInBackgroundWithLoader(Keys.resolvedScoped.value, state.value) {
(logger, workingDir) =>
val cp =
@ -275,6 +295,7 @@ object RunUtil:
val hashClasspath = (bgRun / bgHashClasspath).value
val wrapper = termWrapper(canonicalInput.value, echoInput.value)
val converter = fileConverter.value
setWindowTitle(mkWindowTitle("bgRun", organization.value, name.value, version.value))
service.runInBackgroundWithLoader(Keys.resolvedScoped.value, state.value) {
(logger, workingDir) =>
val cp =

View File

@ -0,0 +1,37 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
package internal
import hedgehog.*
import hedgehog.runner.*
object RunUtilTest extends Properties:
override def tests: List[Test] = List(
property("mkWindowTitle formats title correctly", testMkWindowTitle),
property("mkWindowTitle handles empty strings", testMkWindowTitleEmpty),
)
def testMkWindowTitle: Property =
for
command <- Gen.element1("run", "runMain", "bgRun", "bgRunMain").forAll
org <- Gen.string(Gen.alpha, Range.linear(1, 20)).forAll
name <- Gen.string(Gen.alpha, Range.linear(1, 20)).forAll
version <- Gen.string(Gen.alphaNum, Range.linear(1, 10)).forAll
yield
val result = RunUtil.mkWindowTitle(command, org, name, version)
val expected = s"sbt $command: $org % $name % $version"
Result.assert(result == expected)
def testMkWindowTitleEmpty: Property =
for _ <- Gen.constant(()).forAll
yield
val result = RunUtil.mkWindowTitle("run", "", "", "")
Result.assert(result == "sbt run: % % ")
end RunUtilTest

View File

@ -7,22 +7,23 @@ package sbt.internal.worker
final class RunInfo private (
val jvm: Boolean,
val jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo],
val nativeRunInfo: Option[sbt.internal.worker.NativeRunInfo]) extends Serializable {
val nativeRunInfo: Option[sbt.internal.worker.NativeRunInfo],
val windowTitle: Option[String]) extends Serializable {
private def this(jvm: Boolean, jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo]) = this(jvm, jvmRunInfo, None)
private def this(jvm: Boolean, jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo], windowTitle: Option[String]) = this(jvm, jvmRunInfo, None, windowTitle)
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
case x: RunInfo => (this.jvm == x.jvm) && (this.jvmRunInfo == x.jvmRunInfo) && (this.nativeRunInfo == x.nativeRunInfo)
case x: RunInfo => (this.jvm == x.jvm) && (this.jvmRunInfo == x.jvmRunInfo) && (this.nativeRunInfo == x.nativeRunInfo) && (this.windowTitle == x.windowTitle)
case _ => false
})
override def hashCode: Int = {
37 * (37 * (37 * (37 * (17 + "sbt.internal.worker.RunInfo".##) + jvm.##) + jvmRunInfo.##) + nativeRunInfo.##)
37 * (37 * (37 * (37 * (37 * (17 + "sbt.internal.worker.RunInfo".##) + jvm.##) + jvmRunInfo.##) + nativeRunInfo.##) + windowTitle.##)
}
override def toString: String = {
"RunInfo(" + jvm + ", " + jvmRunInfo + ", " + nativeRunInfo + ")"
"RunInfo(" + jvm + ", " + jvmRunInfo + ", " + nativeRunInfo + ", " + windowTitle + ")"
}
private def copy(jvm: Boolean = jvm, jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo] = jvmRunInfo, nativeRunInfo: Option[sbt.internal.worker.NativeRunInfo] = nativeRunInfo): RunInfo = {
new RunInfo(jvm, jvmRunInfo, nativeRunInfo)
private def copy(jvm: Boolean = jvm, jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo] = jvmRunInfo, nativeRunInfo: Option[sbt.internal.worker.NativeRunInfo] = nativeRunInfo, windowTitle: Option[String] = windowTitle): RunInfo = {
new RunInfo(jvm, jvmRunInfo, nativeRunInfo, windowTitle)
}
def withJvm(jvm: Boolean): RunInfo = {
copy(jvm = jvm)
@ -39,11 +40,17 @@ final class RunInfo private (
def withNativeRunInfo(nativeRunInfo: sbt.internal.worker.NativeRunInfo): RunInfo = {
copy(nativeRunInfo = Option(nativeRunInfo))
}
def withWindowTitle(windowTitle: Option[String]): RunInfo = {
copy(windowTitle = windowTitle)
}
def withWindowTitle(windowTitle: String): RunInfo = {
copy(windowTitle = Option(windowTitle))
}
}
object RunInfo {
def apply(jvm: Boolean, jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo]): RunInfo = new RunInfo(jvm, jvmRunInfo)
def apply(jvm: Boolean, jvmRunInfo: sbt.internal.worker.JvmRunInfo): RunInfo = new RunInfo(jvm, Option(jvmRunInfo))
def apply(jvm: Boolean, jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo], nativeRunInfo: Option[sbt.internal.worker.NativeRunInfo]): RunInfo = new RunInfo(jvm, jvmRunInfo, nativeRunInfo)
def apply(jvm: Boolean, jvmRunInfo: sbt.internal.worker.JvmRunInfo, nativeRunInfo: sbt.internal.worker.NativeRunInfo): RunInfo = new RunInfo(jvm, Option(jvmRunInfo), Option(nativeRunInfo))
def apply(jvm: Boolean, jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo], windowTitle: Option[String]): RunInfo = new RunInfo(jvm, jvmRunInfo, windowTitle)
def apply(jvm: Boolean, jvmRunInfo: sbt.internal.worker.JvmRunInfo, windowTitle: String): RunInfo = new RunInfo(jvm, Option(jvmRunInfo), Option(windowTitle))
def apply(jvm: Boolean, jvmRunInfo: Option[sbt.internal.worker.JvmRunInfo], nativeRunInfo: Option[sbt.internal.worker.NativeRunInfo], windowTitle: Option[String]): RunInfo = new RunInfo(jvm, jvmRunInfo, nativeRunInfo, windowTitle)
def apply(jvm: Boolean, jvmRunInfo: sbt.internal.worker.JvmRunInfo, nativeRunInfo: sbt.internal.worker.NativeRunInfo, windowTitle: String): RunInfo = new RunInfo(jvm, Option(jvmRunInfo), Option(nativeRunInfo), Option(windowTitle))
}

View File

@ -14,8 +14,9 @@ given RunInfoFormat: JsonFormat[sbt.internal.worker.RunInfo] = new JsonFormat[sb
val jvm = unbuilder.readField[Boolean]("jvm")
val jvmRunInfo = unbuilder.readField[Option[sbt.internal.worker.JvmRunInfo]]("jvmRunInfo")
val nativeRunInfo = unbuilder.readField[Option[sbt.internal.worker.NativeRunInfo]]("nativeRunInfo")
val windowTitle = unbuilder.readField[Option[String]]("windowTitle")
unbuilder.endObject()
sbt.internal.worker.RunInfo(jvm, jvmRunInfo, nativeRunInfo)
sbt.internal.worker.RunInfo(jvm, jvmRunInfo, nativeRunInfo, windowTitle)
case None =>
deserializationError("Expected JsObject but found None")
}
@ -25,6 +26,7 @@ given RunInfoFormat: JsonFormat[sbt.internal.worker.RunInfo] = new JsonFormat[sb
builder.addField("jvm", obj.jvm)
builder.addField("jvmRunInfo", obj.jvmRunInfo)
builder.addField("nativeRunInfo", obj.nativeRunInfo)
builder.addField("windowTitle", obj.windowTitle)
builder.endObject()
}
}

View File

@ -37,6 +37,7 @@ type RunInfo {
jvm: Boolean!
jvmRunInfo: sbt.internal.worker.JvmRunInfo,
nativeRunInfo: sbt.internal.worker.NativeRunInfo @since("0.1.0"),
windowTitle: String,
}
## Client-side job support.