diff --git a/src/main/java/de/prob2/jupyter/ProBKernel.java b/src/main/java/de/prob2/jupyter/ProBKernel.java
index 08dc822f2f9f9cab29bc71b648f7a566e144aaff..28d8ca71f4c003bd65e1fa5b3eadb2a81410231f 100644
--- a/src/main/java/de/prob2/jupyter/ProBKernel.java
+++ b/src/main/java/de/prob2/jupyter/ProBKernel.java
@@ -8,6 +8,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -42,6 +43,7 @@ import de.prob2.jupyter.commands.VersionCommand;
 
 import io.github.spencerpark.jupyter.kernel.BaseKernel;
 import io.github.spencerpark.jupyter.kernel.LanguageInfo;
+import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
 import io.github.spencerpark.jupyter.kernel.display.mime.MIMEType;
 
@@ -55,6 +57,7 @@ public final class ProBKernel extends BaseKernel {
 	private static final @NotNull Logger LOGGER = LoggerFactory.getLogger(ProBKernel.class);
 	
 	private static final @NotNull Pattern COMMAND_PATTERN = Pattern.compile("\\s*(\\:[^\\s]*)(?:\\h*(.*))?", Pattern.DOTALL);
+	private static final @NotNull Pattern SPACE_PATTERN = Pattern.compile("\\s*");
 	private static final @NotNull Pattern BSYMB_COMMAND_PATTERN = Pattern.compile("\\\\([a-z]+)");
 	
 	private static final @NotNull Map<@NotNull String, @NotNull String> BSYMB_COMMAND_DEFINITIONS;
@@ -213,6 +216,55 @@ public final class ProBKernel extends BaseKernel {
 		return this.executeCommand(":eval", expr);
 	}
 	
+	@Override
+	public @Nullable ReplacementOptions complete(final @NotNull String code, final int at) {
+		final Matcher commandMatcher = COMMAND_PATTERN.matcher(code);
+		if (commandMatcher.matches()) {
+			// The code is a valid command.
+			final int argOffset = commandMatcher.start(2);
+			if (at <= commandMatcher.end(1)) {
+				// The cursor is somewhere in the command name, provide command completions.
+				final String prefix = code.substring(commandMatcher.start(1), at);
+				return new ReplacementOptions(
+					this.getCommands().keySet().stream().filter(s -> s.startsWith(prefix)).sorted().collect(Collectors.toList()),
+					commandMatcher.start(1), 
+					commandMatcher.end(1)
+				);
+			} else if (at < commandMatcher.start(2)) {
+				// The cursor is in the whitespace between the command name and arguments, don't show anything.
+				return null;
+			} else {
+				// The cursor is somewhere in the command arguments, ask the command to provide completions.
+				final String name = commandMatcher.group(1);
+				assert name != null;
+				final String argString = commandMatcher.group(2) == null ? "" : commandMatcher.group(2);
+				if (this.getCommands().containsKey(name)) {
+					final ReplacementOptions replacements = this.getCommands().get(name).complete(this, argString, at - argOffset);
+					return replacements == null ? null : new ReplacementOptions(
+						replacements.getReplacements(),
+						replacements.getSourceStart() + argOffset,
+						replacements.getSourceEnd() + argOffset
+					);
+				} else {
+					// Invalid command, can't provide any completions.
+					return null;
+				}
+			}
+		} else if (SPACE_PATTERN.matcher(code).matches()) {
+			// The code contains only whitespace, provide completions from :eval and for command names.
+			final List<String> replacementStrings = new ArrayList<>();
+			final ReplacementOptions evalReplacements = this.getCommands().get(":eval").complete(this, code, at);
+			if (evalReplacements != null) {
+				replacementStrings.addAll(evalReplacements.getReplacements());
+			}
+			replacementStrings.addAll(this.getCommands().keySet().stream().sorted().collect(Collectors.toList()));
+			return new ReplacementOptions(replacementStrings, at, at);
+		} else {
+			// The code is not a valid command, ask :eval for completions.
+			return this.getCommands().get(":eval").complete(this, code, at);
+		}
+	}
+	
 	@Override
 	public @NotNull LanguageInfo getLanguageInfo() {
 		return new LanguageInfo.Builder("prob")
diff --git a/src/main/java/de/prob2/jupyter/commands/Command.java b/src/main/java/de/prob2/jupyter/commands/Command.java
index c55ed79bf7ff9168b067d70a2cdbf2bb57c2ce87..460e403afc4095847efc0f74e23c71eda7d56be0 100644
--- a/src/main/java/de/prob2/jupyter/commands/Command.java
+++ b/src/main/java/de/prob2/jupyter/commands/Command.java
@@ -2,6 +2,7 @@ package de.prob2.jupyter.commands;
 
 import de.prob2.jupyter.ProBKernel;
 
+import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
 
 import org.jetbrains.annotations.NotNull;
@@ -13,4 +14,8 @@ public interface Command {
 	public abstract @NotNull String getShortHelp();
 	
 	public abstract @Nullable DisplayData run(final @NotNull ProBKernel kernel, final @NotNull String argString);
+	
+	public default @Nullable ReplacementOptions complete(final @NotNull ProBKernel kernel, final @NotNull String argString, final int at) {
+		return null;
+	}
 }
diff --git a/src/main/java/de/prob2/jupyter/commands/HelpCommand.java b/src/main/java/de/prob2/jupyter/commands/HelpCommand.java
index ae6cac670ba32c1c276bf0df478e190f769268bd..6b2fafaf18d98f015088ecb6adaade3eda55f004 100644
--- a/src/main/java/de/prob2/jupyter/commands/HelpCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/HelpCommand.java
@@ -2,12 +2,14 @@ package de.prob2.jupyter.commands;
 
 import java.util.List;
 import java.util.TreeMap;
+import java.util.stream.Collectors;
 
 import com.google.inject.Inject;
 
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
+import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
 
 import org.jetbrains.annotations.NotNull;
@@ -71,4 +73,14 @@ public final class HelpCommand implements Command {
 			throw new UserErrorException("Expected at most 1 argument, got " + args.size());
 		}
 	}
+	
+	@Override
+	public @NotNull ReplacementOptions complete(final @NotNull ProBKernel kernel, final @NotNull String argString, final int at) {
+		final String prefix = argString.substring(0, at);
+		return new ReplacementOptions(
+			kernel.getCommands().keySet().stream().filter(s -> s.startsWith(prefix)).sorted().collect(Collectors.toList()),
+			0,
+			argString.length()
+		);
+	}
 }
diff --git a/src/main/java/de/prob2/jupyter/commands/TimeCommand.java b/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
index e9afce88c2450128c10a4ba629e7766f7fbddbc6..9148485ad81e64874adbd52ec594e46bb17f146b 100644
--- a/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
@@ -4,6 +4,7 @@ import com.google.inject.Inject;
 
 import de.prob2.jupyter.ProBKernel;
 
+import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
 
 import org.jetbrains.annotations.NotNull;
@@ -39,4 +40,9 @@ public final class TimeCommand implements Command {
 		kernel.display(timeDisplay);
 		return result;
 	}
+	
+	@Override
+	public @Nullable ReplacementOptions complete(final @NotNull ProBKernel kernel, final @NotNull String argString, final int at) {
+		return kernel.complete(argString, at);
+	}
 }