Repository: bombshell-dev/clack
Pull request #276: feat: add taskLog prompt
Author: login: 43081j
Created: 2025-04-06T18:54:02Z | Merged: 2025-04-15T12:40:59Z

Adds a new taskLog prompt which renders messages then ultimately does one of the following:

- If error, retain the output and render the error message
- If success, clear the output and render the success message

For example:

```ts
const log = prompts.taskLog({
  message: 'some message'
});

log.message('a line of text');
log.message('another line of text');
log.message('multiple\nlines\nof\ntext');

// at this point, it will have rendered like so:
// o some message
// | a line of text
// | another line of text
// ...

log.error('some error');

// if the line above runs, it will render like so:
// x some error
// | a line of text
// | another line of text
// ...

log.success('some success');

// if the line above runs, it will clear and render like so:
// o some success
```

part of bringing svelte CLI back to using clack instead of a fork


Diff:
diff --git a/.changeset/famous-turkeys-burn.md b/.changeset/famous-turkeys-burn.md
new file mode 100644
index 00000000..4705b7ea
--- /dev/null
+++ b/.changeset/famous-turkeys-burn.md
@@ -0,0 +1,5 @@
+---
+"@clack/prompts": minor
+---
+
+Added new `taskLog` prompt for log output which is cleared on success
diff --git a/packages/prompts/README.md b/packages/prompts/README.md
index 2e297ee6..12961db2 100644
--- a/packages/prompts/README.md
+++ b/packages/prompts/README.md
@@ -204,4 +204,26 @@ stream.error((function *() { yield 'Error!'; })());
 stream.message((function *() { yield 'Hello'; yield ", World" })(), { symbol: color.cyan('~') });
 ```
 
+### Task Log
+
+When executing a sub-process or a similar sub-task, `taskLog` can be used to render the output continuously and clear it at the end if it was successful.
+
+```js
+import { taskLog } from '@clack/prompts';
+
+const log = taskLog({
+	message: 'Running npm install'
+});
+
+for await (const line of npmInstall()) {
+	log.message(line);
+}
+
+if (success) {
+	log.success('Done!');
+} else {
+	log.error('Failed!');
+}
+```
+
 ![clack-log-prompts](https://github.com/bombshell-dev/clack/blob/main/.github/assets/clack-logs.png)
diff --git a/packages/prompts/src/__snapshots__/index.test.ts.snap b/packages/prompts/src/__snapshots__/index.test.ts.snap
index 6437aefe..b4b5a947 100644
--- a/packages/prompts/src/__snapshots__/index.test.ts.snap
+++ b/packages/prompts/src/__snapshots__/index.test.ts.snap
@@ -1781,6 +1781,600 @@ exports[`prompts (isCI = false) > spinner > stop > renders submit symbol and sto
 ]
 `;
 
+exports[`prompts (isCI = false) > taskLog > error > clears output if showLog = false 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  some error!
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > error > renders output with message 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  some error!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > message > can write line by line 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > message > can write multiple lines 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > message > enforces limit if set 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > message > prints empty lines 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2m[22m
+",
+  "[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2m[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2m[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > message > raw = true appends message text until newline 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0still line 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0still line 0[22m
+[2m│[22m  [2mline 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > message > raw = true works when mixed with non-raw messages 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0still line 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0still line 0[22m
+[2m│[22m  [2mline 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > message > raw = true works when started with non-raw messages 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1still line 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > error > outputs limited log with limit by default 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > error > retainLog = false outputs full log without limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > error > retainLog = false outputs limited log with limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > error > retainLog = true outputs full log 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > error > retainLog = true outputs full log with limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > success > outputs limited log with limit by default 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > success > retainLog = false outputs full log without limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > success > retainLog = false outputs limited log with limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > success > retainLog = true outputs full log 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > retainLog > success > retainLog = true outputs full log with limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+",
+  "[2K[1A[2K[1A[2K[G",
+  "[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > success > clears output and renders message 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  success!
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > success > renders output if showLog = true 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2m│[22m  [2mline 0[22m
+",
+  "[2K[1A[2K[G",
+  "[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  success!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = false) > taskLog > writes message header 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+]
+`;
+
 exports[`prompts (isCI = false) > text > can cancel 1`] = `
 [
   "[?25l",
@@ -3774,6 +4368,409 @@ exports[`prompts (isCI = true) > spinner > stop > renders submit symbol and stop
 ]
 `;
 
+exports[`prompts (isCI = true) > taskLog > error > clears output if showLog = false 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  some error!
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > error > renders output with message 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  some error!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > message > can write line by line 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > message > can write multiple lines 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > message > enforces limit if set 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > message > prints empty lines 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > message > raw = true appends message text until newline 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[G",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > message > raw = true works when mixed with non-raw messages 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[G",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > message > raw = true works when started with non-raw messages 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > error > outputs limited log with limit by default 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > error > retainLog = false outputs full log without limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > error > retainLog = false outputs limited log with limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > error > retainLog = true outputs full log 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > error > retainLog = true outputs full log with limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[31m■[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > success > outputs limited log with limit by default 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > success > retainLog = false outputs full log without limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > success > retainLog = false outputs limited log with limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > success > retainLog = true outputs full log 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > retainLog > success > retainLog = true outputs full log with limit 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  woo!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+[2m│[22m  [2mline 2[22m
+[2m│[22m  [2mline 3[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > success > clears output and renders message 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  success!
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > success > renders output if showLog = true 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+  "[2K[1A[2K[G",
+  "[2K[1A[2K[1A[2K[1A[2K[1A[2K[1A[2K[G",
+  "[2m│[22m
+[32m◆[39m  success!
+",
+  "[2m│[22m
+[2m│[22m  [2mline 0[22m
+[2m│[22m  [2mline 1[22m
+",
+]
+`;
+
+exports[`prompts (isCI = true) > taskLog > writes message header 1`] = `
+[
+  "[2m│[22m
+",
+  "[32m◇[39m  foo
+",
+  "[2m│[22m
+",
+]
+`;
+
 exports[`prompts (isCI = true) > text > can cancel 1`] = `
 [
   "[?25l",
diff --git a/packages/prompts/src/index.test.ts b/packages/prompts/src/index.test.ts
index 3a41d7db..0d28c0aa 100644
--- a/packages/prompts/src/index.test.ts
+++ b/packages/prompts/src/index.test.ts
@@ -1416,4 +1416,270 @@ describe.each(['true', 'false'])('prompts (isCI = %s)', (isCI) => {
 			expect(output.buffer).toMatchSnapshot();
 		});
 	});
+
+	describe('taskLog', () => {
+		test('writes message header', () => {
+			prompts.taskLog({
+				input,
+				output,
+				title: 'foo',
+			});
+
+			expect(output.buffer).toMatchSnapshot();
+		});
+
+		describe('message', () => {
+			test('can write line by line', () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0');
+				log.message('line 1');
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+
+			test('can write multiple lines', () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0\nline 1');
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+
+			test('enforces limit if set', () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+					limit: 2,
+				});
+
+				log.message('line 0');
+				log.message('line 1');
+				log.message('line 2');
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+
+			test('raw = true appends message text until newline', async () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0', { raw: true });
+				log.message('still line 0', { raw: true });
+				log.message('\nline 1', { raw: true });
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+
+			test('raw = true works when mixed with non-raw messages', async () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0', { raw: true });
+				log.message('still line 0', { raw: true });
+				log.message('line 1');
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+
+			test('raw = true works when started with non-raw messages', async () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0');
+				log.message('line 1', { raw: true });
+				log.message('still line 1', { raw: true });
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+
+			test('prints empty lines', async () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('');
+				log.message('line 1');
+				log.message('');
+				log.message('line 3');
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+		});
+
+		describe('error', () => {
+			test('renders output with message', () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0');
+				log.message('line 1');
+
+				log.error('some error!');
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+
+			test('clears output if showLog = false', () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0');
+				log.message('line 1');
+
+				log.error('some error!', { showLog: false });
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+		});
+
+		describe('success', () => {
+			test('clears output and renders message', () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0');
+				log.message('line 1');
+
+				log.success('success!');
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+
+			test('renders output if showLog = true', () => {
+				const log = prompts.taskLog({
+					input,
+					output,
+					title: 'foo',
+				});
+
+				log.message('line 0');
+				log.message('line 1');
+
+				log.success('success!', { showLog: true });
+
+				expect(output.buffer).toMatchSnapshot();
+			});
+		});
+
+		describe('retainLog', () => {
+			describe.each(['error', 'success'] as const)('%s', (method) => {
+				test('retainLog = true outputs full log', () => {
+					const log = prompts.taskLog({
+						input,
+						output,
+						title: 'foo',
+						retainLog: true,
+					});
+
+					for (let i = 0; i < 4; i++) {
+						log.message(`line ${i}`);
+					}
+
+					log[method]('woo!', { showLog: true });
+
+					expect(output.buffer).toMatchSnapshot();
+				});
+
+				test('retainLog = true outputs full log with limit', () => {
+					const log = prompts.taskLog({
+						input,
+						output,
+						title: 'foo',
+						retainLog: true,
+						limit: 2,
+					});
+
+					for (let i = 0; i < 4; i++) {
+						log.message(`line ${i}`);
+					}
+
+					log[method]('woo!', { showLog: true });
+
+					expect(output.buffer).toMatchSnapshot();
+				});
+
+				test('retainLog = false outputs full log without limit', () => {
+					const log = prompts.taskLog({
+						input,
+						output,
+						title: 'foo',
+						retainLog: false,
+					});
+
+					for (let i = 0; i < 4; i++) {
+						log.message(`line ${i}`);
+					}
+
+					log[method]('woo!', { showLog: true });
+
+					expect(output.buffer).toMatchSnapshot();
+				});
+
+				test('retainLog = false outputs limited log with limit', () => {
+					const log = prompts.taskLog({
+						input,
+						output,
+						title: 'foo',
+						retainLog: false,
+						limit: 2,
+					});
+
+					for (let i = 0; i < 4; i++) {
+						log.message(`line ${i}`);
+					}
+
+					log[method]('woo!', { showLog: true });
+
+					expect(output.buffer).toMatchSnapshot();
+				});
+
+				test('outputs limited log with limit by default', () => {
+					const log = prompts.taskLog({
+						input,
+						output,
+						title: 'foo',
+						limit: 2,
+					});
+
+					for (let i = 0; i < 4; i++) {
+						log.message(`line ${i}`);
+					}
+
+					log[method]('woo!', { showLog: true });
+
+					expect(output.buffer).toMatchSnapshot();
+				});
+			});
+		});
+	});
 });
diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts
index d49c735b..137f3750 100644
--- a/packages/prompts/src/index.ts
+++ b/packages/prompts/src/index.ts
@@ -63,6 +63,13 @@ const symbol = (state: State) => {
 	}
 };
 
+const getColumns = (output: Writable): number => {
+	if (output instanceof WriteStream && output.columns) {
+		return output.columns;
+	}
+	return 80;
+};
+
 interface LimitOptionsParams<TOption> extends CommonOptions {
 	options: TOption[];
 	maxItems: number | undefined;
@@ -691,16 +698,38 @@ export const outro = (message = '', opts?: CommonOptions) => {
 
 export interface LogMessageOptions extends CommonOptions {
 	symbol?: string;
+	spacing?: number;
+	secondarySymbol?: string;
 }
 export const log = {
 	message: (
-		message = '',
-		{ symbol = color.gray(S_BAR), output = process.stdout }: LogMessageOptions = {}
+		message: string | string[] = [],
+		{
+			symbol = color.gray(S_BAR),
+			secondarySymbol = color.gray(S_BAR),
+			output = process.stdout,
+			spacing = 1,
+		}: LogMessageOptions = {}
 	) => {
-		const parts = [`${color.gray(S_BAR)}`];
-		if (message) {
-			const [firstLine, ...lines] = message.split('\n');
-			parts.push(`${symbol}  ${firstLine}`, ...lines.map((ln) => `${color.gray(S_BAR)}  ${ln}`));
+		const parts: string[] = [];
+		for (let i = 0; i < spacing; i++) {
+			parts.push(`${secondarySymbol}`);
+		}
+		const messageParts = Array.isArray(message) ? message : message.split('\n');
+		if (messageParts.length > 0) {
+			const [firstLine, ...lines] = messageParts;
+			if (firstLine.length > 0) {
+				parts.push(`${symbol}  ${firstLine}`);
+			} else {
+				parts.push(symbol);
+			}
+			for (const ln of lines) {
+				if (ln.length > 0) {
+					parts.push(`${secondarySymbol}  ${ln}`);
+				} else {
+					parts.push(secondarySymbol);
+				}
+			}
 		}
 		output.write(`${parts.join('\n')}\n`);
 	},
@@ -1014,3 +1043,113 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	title: string;
+	limit?: number;
+	spacing?: number;
+	retainLog?: boolean;
+}
+
+export interface TaskLogMessageOptions {
+	raw?: boolean;
+}
+
+export interface TaskLogCompletionOptions {
+	showLog?: boolean;
+}
+
+/**
+ * Renders a log which clears on success and remains on failure
+ */
+export const taskLog = (opts: TaskLogOptions) => {
+	const output: Writable = opts.output ?? process.stdout;
+	const columns = getColumns(output);
+	const secondarySymbol = color.dim(S_BAR);
+	const spacing = opts.spacing ?? 1;
+	const barSize = 3;
+	const retainLog = opts.retainLog === true;
+	const isCI = process.env.CI === 'true';
+
+	output.write(`${secondarySymbol}\n`);
+	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.title}\n`);
+	for (let i = 0; i < spacing; i++) {
+		output.write(`${secondarySymbol}\n`);
+	}
+
+	let buffer = '';
+	let fullBuffer = '';
+	let lastMessageWasRaw = false;
+
+	const clear = (clearTitle: boolean): void => {
+		if (buffer.length === 0) {
+			return;
+		}
+		const bufferHeight = buffer.split('\n').reduce((count, line) => {
+			if (line === '') {
+				return count + 1;
+			}
+			return count + Math.ceil((line.length + barSize) / columns);
+		}, 0);
+		const lines = bufferHeight + 1 + (clearTitle ? spacing + 2 : 0);
+		output.write(erase.lines(lines));
+	};
+	const printBuffer = (buf: string, messageSpacing?: number): void => {
+		log.message(buf.split('\n').map(color.dim), {
+			output,
+			secondarySymbol,
+			symbol: secondarySymbol,
+			spacing: messageSpacing ?? spacing,
+		});
+	};
+	const renderBuffer = (): void => {
+		if (retainLog === true && fullBuffer.length > 0) {
+			printBuffer(`${fullBuffer}\n${buffer}`);
+		} else {
+			printBuffer(buffer);
+		}
+	};
+
+	return {
+		message(msg: string, mopts?: TaskLogMessageOptions) {
+			clear(false);
+			if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer !== '') {
+				buffer += '\n';
+			}
+			buffer += msg;
+			lastMessageWasRaw = mopts?.raw === true;
+			if (opts.limit !== undefined) {
+				const lines = buffer.split('\n');
+				const linesToRemove = lines.length - opts.limit;
+				if (linesToRemove > 0) {
+					const removedLines = lines.splice(0, linesToRemove);
+					if (retainLog) {
+						fullBuffer += (fullBuffer === '' ? '' : '\n') + removedLines.join('\n');
+					}
+				}
+				buffer = lines.join('\n');
+			}
+			if (!isCI) {
+				printBuffer(buffer, 0);
+			}
+		},
+		error(message: string, opts?: TaskLogCompletionOptions): void {
+			clear(true);
+			log.error(message, { output, secondarySymbol, spacing: 1 });
+			if (opts?.showLog !== false) {
+				renderBuffer();
+			}
+			// clear buffer since error is an end state
+			buffer = fullBuffer = '';
+		},
+		success(message: string, opts?: TaskLogCompletionOptions): void {
+			clear(true);
+			log.success(message, { output, secondarySymbol, spacing: 1 });
+			if (opts?.showLog === true) {
+				renderBuffer();
+			}
+			// clear buffer since success is an end state
+			buffer = fullBuffer = '';
+		},
+	};
+};


Reviews:
user: login: manuel3108
body: Overall this looks perfect, although I'm having trouble integrating this into the svelte cli `sv`. But maybe it's just too late for me.

This is how it's currently implemented:
https://github.com/sveltejs/cli/blob/ccb512d1bb48992dcff9cacadd063085789786fd/packages/cli/utils/package-manager.ts#L43-L69

Currently the following exception is printed:
```
TypeError: a.split is not a function
```
I'm assuming this is related to this pr, but from the code i don't understand exactly where that is happening.
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-06T20:01:23Z
user: login: AdrianGonz97
body: thanks for your work on this!! 

aside from the suggested changes below, it would also be nice if the rendered logs were dimmed with `colors.dim(..)` so that they'd have some nice contrast from the title

e.g. in `sv`, normal and error logs would appear as so:
![img](https://i.imgur.com/OefRdTQ.png)
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-08T20:48:05Z
user: login: 43081j
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T08:53:08Z
user: login: 43081j
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T08:54:50Z
user: login: 43081j
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T08:57:37Z
user: login: AdrianGonz97
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-09T15:27:24Z
user: login: AdrianGonz97
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-09T15:28:02Z
user: login: AdrianGonz97
body: `log.message` ended up being the culprit of the partial coloring. more specifically, the coloring of the symbols of the gutter (`│`). 

as the symbols contained the same ansi escape codes as the message's content (e.g. `\x1B[2m│\x1B[22m  \x1B[2msome message goes here\x1B[22m`), the terminating end of the ansi code was not registering properly in multi-line messages.

for example:
```
\x1B[2m│\x1B[22m  \x1B[2mfoo
\x1B[2m│\x1B[22m  bar
\x1B[2m│\x1B[22m  baz
\x1B[2m│\x1B[22m  \x1B[22m
```

it thinks the sequence terminates on the `\x1B[22m` in the second line. as a result, only the first line of the message is properly colored.

the solution was to have each line wrapped with the proper code:
```
\x1B[2m│\x1B[22m  \x1B[2mfoo\x1B[22m
\x1B[2m│\x1B[22m  \x1B[2mbar\x1B[22m
\x1B[2m│\x1B[22m  \x1B[2mbaz\x1B[22m
```
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-10T01:51:35Z
user: login: 43081j
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T08:56:31Z
user: login: dreyfus92
body: this is pretty solid james, LGTM 🫡
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-12T20:02:07Z
user: login: natemoo-re
body: Looks awesome! My only comment is on naming—does `taskLog` effectively communicate what this does? Has something like `subtask()`, `subprocess()`, or just `pipe()` been considered?

Not going to block this on an imperfect name, though. Code looks great! Whatever you decide is great 😊 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-12T20:12:08Z
user: login: manuel3108
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-13T14:32:30Z
user: login: 43081j
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-13T14:48:06Z
user: login: manuel3108
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-13T15:21:55Z
user: login: 43081j
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-14T08:49:04Z

Review comments:
diff_hunk: @@ -204,4 +204,26 @@ stream.error((function *() { yield 'Error!'; })());
 stream.message((function *() { yield 'Hello'; yield ", World" })(), { symbol: color.cyan('~') });
 ```
 
+### Task Log
+
+When executing a sub-process or a similar sub-task, `taskLog` can be used to render the output continuously and clear it at the end if it was successful.
+
+```js
+import { taskLog } from '@clack/prompts';
+
+const log = taskLog({
+	message: 'Running npm install'
+});
+
+for await (const line of npmInstall()) {
path: packages/prompts/README.md
user: login: manuel3108
body: ```suggestion
for await (const line of npmInstall()) {
```
Not sure if the `npmInstall` makes it clear enough for the people, but I don't have any better idea
created_at: 2025-04-06T19:53:56Z
author_association: NONE
diff_hunk: @@ -995,3 +1024,80 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	message: string;
+	limit?: number;
+}
+
+export interface TaskLogMessageOptions {
+	raw?: boolean;
+}
+
+/**
+ * Renders a log which clears on success and remains on failure
+ */
+export const taskLog = (opts: TaskLogOptions) => {
+	const output: Writable = opts.output ?? process.stdout;
+	const columns = getColumns(output);
+	const secondarySymbol = color.dim(S_BAR);
+
+	output.write(`${secondarySymbol}\n`);
+	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.message}\n`);
+	output.write(`${secondarySymbol}\n`);
+
+	let buffer = '';
+	let lastMessageWasRaw = false;
+
+	const clear = (clearTitle: boolean): void => {
+		if (buffer.length === 0) {
+			return;
+		}
+		const bufferHeight = buffer.split('\n').reduce((count, line) => {
+			if (line === '') {
+				return count + 1;
+			}
+			return count + Math.ceil(line.length / columns);
+		}, 0);
+		const lines = bufferHeight + 1 + (lastMessageWasRaw ? 1 : 0) + (clearTitle ? 3 : 0);
+		output.write(erase.lines(lines));
+	};
+
+	return {
+		message(msg: string, mopts?: TaskLogMessageOptions) {
+			clear(false);
+			if (mopts?.raw) {
+				buffer += msg;
+			} else {
+				if (lastMessageWasRaw) {
+					buffer += '\n';
+				}
+				buffer += `${msg}\n`;
+			}
+			lastMessageWasRaw = mopts?.raw === true;
+			if (opts.limit !== undefined) {
+				const lines = buffer.split('\n');
+				const linesToRemove = lines.length - opts.limit - (lastMessageWasRaw ? 0 : 1);
+				if (linesToRemove > 0) {
+					lines.splice(0, linesToRemove);
+				}
+				buffer = lines.join('\n');
+			}
+			log.message(lastMessageWasRaw ? `${buffer}\n` : buffer, {
+				output,
+				secondarySymbol,
+				symbol: secondarySymbol,
+				spacing: 0,
+			});
+		},
+		error(message: string): void {
+			clear(true);
+			log.error(message, { output, secondarySymbol, spacing: 1 });
+			log.message(buffer, { output, secondarySymbol, symbol: secondarySymbol, spacing: 1 });
+		},
+		success(message: string): void {
+			clear(true);
+			log.success(message, { output, secondarySymbol, spacing: 1 });
+		},
+	};
+};
path: packages/prompts/src/index.ts
user: login: AdrianGonz97
body: (apologies for the large diff)

the below suggestions tweaks the following:
- removed the spacing between the title and the logs
- errors now renders out the full log

```suggestion
export const taskLog = (opts: TaskLogOptions) => {
	const output: Writable = opts.output ?? process.stdout;
	const columns = getColumns(output);
	const secondarySymbol = color.dim(S_BAR);

	output.write(`${secondarySymbol}\n`);
	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.message}\n`);

	let buffer = '';
	let lastMessageWasRaw = false;
	const frame = () => {
		if (opts.limit !== undefined) {
			const lines = buffer.split('\n');
			const linesToRemove = lines.length - opts.limit - (lastMessageWasRaw ? 0 : 1);
			if (linesToRemove > 0) {
				lines.splice(0, linesToRemove);
			}
			return lines.join('\n');
		}
		return buffer;
	};

	const clear = (clearTitle: boolean): void => {
		if (buffer.length === 0) {
			return;
		}
		const frameHeight = frame()
			.split('\n')
			.reduce((count, line) => {
				if (line === '') {
					return count + 1;
				}
				return count + Math.ceil(line.length / columns);
			}, 0);
		const lines = frameHeight + (lastMessageWasRaw ? 1 : 0) + (clearTitle ? 1 : 0);
		output.write(erase.lines(lines));
	};

	return {
		message(msg: string, mopts?: TaskLogMessageOptions) {
			clear(false);
			if (mopts?.raw) {
				buffer += msg;
			} else {
				if (lastMessageWasRaw) {
					buffer += '\n';
				}
				buffer += `${msg}\n`;
			}
			lastMessageWasRaw = mopts?.raw === true;
			log.message(frame(), {
				output,
				secondarySymbol,
				symbol: secondarySymbol,
				spacing: 0,
			});
		},
		error(message: string): void {
			clear(true);
			log.error(message, { output, secondarySymbol, spacing: 0 });
			log.message(buffer, {
				output,
				secondarySymbol,
				symbol: secondarySymbol,
				spacing: 0,
			});
		},
		success(message: string): void {
			clear(true);
			log.success(message, { output, secondarySymbol, spacing: 1 });
		},
	};
};
```
created_at: 2025-04-08T20:41:41Z
author_association: NONE
diff_hunk: @@ -995,3 +1024,80 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	message: string;
path: packages/prompts/src/index.ts
user: login: AdrianGonz97
body: `message` feels a bit too close to the `taskLog.message(...)` method and makes it seem like they are connected. i think it might be clearer if it was just called `title`.
```suggestion
	title: string;
```
created_at: 2025-04-08T20:45:44Z
author_association: NONE
diff_hunk: @@ -995,3 +1024,80 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	message: string;
+	limit?: number;
+}
+
+export interface TaskLogMessageOptions {
+	raw?: boolean;
+}
+
+/**
+ * Renders a log which clears on success and remains on failure
+ */
+export const taskLog = (opts: TaskLogOptions) => {
+	const output: Writable = opts.output ?? process.stdout;
+	const columns = getColumns(output);
+	const secondarySymbol = color.dim(S_BAR);
+
+	output.write(`${secondarySymbol}\n`);
+	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.message}\n`);
+	output.write(`${secondarySymbol}\n`);
+
+	let buffer = '';
+	let lastMessageWasRaw = false;
+
+	const clear = (clearTitle: boolean): void => {
+		if (buffer.length === 0) {
+			return;
+		}
+		const bufferHeight = buffer.split('\n').reduce((count, line) => {
+			if (line === '') {
+				return count + 1;
+			}
+			return count + Math.ceil(line.length / columns);
+		}, 0);
+		const lines = bufferHeight + 1 + (lastMessageWasRaw ? 1 : 0) + (clearTitle ? 3 : 0);
+		output.write(erase.lines(lines));
+	};
+
+	return {
+		message(msg: string, mopts?: TaskLogMessageOptions) {
+			clear(false);
+			if (mopts?.raw) {
+				buffer += msg;
+			} else {
+				if (lastMessageWasRaw) {
+					buffer += '\n';
+				}
+				buffer += `${msg}\n`;
+			}
+			lastMessageWasRaw = mopts?.raw === true;
+			if (opts.limit !== undefined) {
+				const lines = buffer.split('\n');
+				const linesToRemove = lines.length - opts.limit - (lastMessageWasRaw ? 0 : 1);
+				if (linesToRemove > 0) {
+					lines.splice(0, linesToRemove);
+				}
+				buffer = lines.join('\n');
+			}
+			log.message(lastMessageWasRaw ? `${buffer}\n` : buffer, {
+				output,
+				secondarySymbol,
+				symbol: secondarySymbol,
+				spacing: 0,
+			});
+		},
+		error(message: string): void {
+			clear(true);
+			log.error(message, { output, secondarySymbol, spacing: 1 });
+			log.message(buffer, { output, secondarySymbol, symbol: secondarySymbol, spacing: 1 });
+		},
+		success(message: string): void {
+			clear(true);
+			log.success(message, { output, secondarySymbol, spacing: 1 });
+		},
+	};
+};
path: packages/prompts/src/index.ts
user: login: 43081j
body: can you show me what exactly you changed as this diff isn't very helpful 😬 

also because some of what you're suggesting is already being worked on, so explaining the problem is better than suggesting specific code

im just going to have to copy this locally and diff it there anyway...
created_at: 2025-04-09T08:53:08Z
author_association: COLLABORATOR
diff_hunk: @@ -995,3 +1024,80 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	message: string;
path: packages/prompts/src/index.ts
user: login: 43081j
body: this is on purpose because pretty much every other prompt uses `message` for the primary message

i understand what you're saying, but is that worth breaking consistency?
created_at: 2025-04-09T08:54:50Z
author_association: COLLABORATOR
diff_hunk: @@ -995,3 +1024,80 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	message: string;
+	limit?: number;
+}
+
+export interface TaskLogMessageOptions {
+	raw?: boolean;
+}
+
+/**
+ * Renders a log which clears on success and remains on failure
+ */
+export const taskLog = (opts: TaskLogOptions) => {
+	const output: Writable = opts.output ?? process.stdout;
+	const columns = getColumns(output);
+	const secondarySymbol = color.dim(S_BAR);
+
+	output.write(`${secondarySymbol}\n`);
+	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.message}\n`);
+	output.write(`${secondarySymbol}\n`);
+
+	let buffer = '';
+	let lastMessageWasRaw = false;
+
+	const clear = (clearTitle: boolean): void => {
+		if (buffer.length === 0) {
+			return;
+		}
+		const bufferHeight = buffer.split('\n').reduce((count, line) => {
+			if (line === '') {
+				return count + 1;
+			}
+			return count + Math.ceil(line.length / columns);
+		}, 0);
+		const lines = bufferHeight + 1 + (lastMessageWasRaw ? 1 : 0) + (clearTitle ? 3 : 0);
+		output.write(erase.lines(lines));
+	};
+
+	return {
+		message(msg: string, mopts?: TaskLogMessageOptions) {
+			clear(false);
+			if (mopts?.raw) {
+				buffer += msg;
+			} else {
+				if (lastMessageWasRaw) {
+					buffer += '\n';
+				}
+				buffer += `${msg}\n`;
+			}
+			lastMessageWasRaw = mopts?.raw === true;
+			if (opts.limit !== undefined) {
+				const lines = buffer.split('\n');
+				const linesToRemove = lines.length - opts.limit - (lastMessageWasRaw ? 0 : 1);
+				if (linesToRemove > 0) {
+					lines.splice(0, linesToRemove);
+				}
+				buffer = lines.join('\n');
+			}
+			log.message(lastMessageWasRaw ? `${buffer}\n` : buffer, {
+				output,
+				secondarySymbol,
+				symbol: secondarySymbol,
+				spacing: 0,
+			});
+		},
+		error(message: string): void {
+			clear(true);
+			log.error(message, { output, secondarySymbol, spacing: 1 });
+			log.message(buffer, { output, secondarySymbol, symbol: secondarySymbol, spacing: 1 });
+		},
+		success(message: string): void {
+			clear(true);
+			log.success(message, { output, secondarySymbol, spacing: 1 });
+		},
+	};
+};
path: packages/prompts/src/index.ts
user: login: 43081j
body: i will implement these myself (as i was already working on the second one):

> * removed the spacing between the title and the logs
> * errors now renders out the full log

the first one is as simple as removing the `write` of the separator after the title

the second isn't as simple as what you've suggested here.

the default should be that we _do not_ retain the entire buffer as otherwise it means higher memory usage for everyone when not everyone needs this behaviour. my plan was already to include a new option which lets you toggle this behaviour, so anyone who does want to retain the entire buffer can do so via an option, but the default is to discard it.

does that make sense? something like `retainLogOnError: true` but a better name
created_at: 2025-04-09T08:57:37Z
author_association: COLLABORATOR
diff_hunk: @@ -995,3 +1024,80 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	message: string;
path: packages/prompts/src/index.ts
user: login: AdrianGonz97
body: it shouldn't be that inconsistent given that `note`'s and `task`'s also provide `title` props: https://github.com/bombshell-dev/clack/blob/3552578ad867f144e10cf33dbd4d46960cace0c9/packages/prompts/src/index.ts#L642

https://github.com/bombshell-dev/clack/blob/3552578ad867f144e10cf33dbd4d46960cace0c9/packages/prompts/src/index.ts#L973-L987
created_at: 2025-04-09T15:27:24Z
author_association: NONE
diff_hunk: @@ -995,3 +1024,80 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	message: string;
+	limit?: number;
+}
+
+export interface TaskLogMessageOptions {
+	raw?: boolean;
+}
+
+/**
+ * Renders a log which clears on success and remains on failure
+ */
+export const taskLog = (opts: TaskLogOptions) => {
+	const output: Writable = opts.output ?? process.stdout;
+	const columns = getColumns(output);
+	const secondarySymbol = color.dim(S_BAR);
+
+	output.write(`${secondarySymbol}\n`);
+	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.message}\n`);
+	output.write(`${secondarySymbol}\n`);
+
+	let buffer = '';
+	let lastMessageWasRaw = false;
+
+	const clear = (clearTitle: boolean): void => {
+		if (buffer.length === 0) {
+			return;
+		}
+		const bufferHeight = buffer.split('\n').reduce((count, line) => {
+			if (line === '') {
+				return count + 1;
+			}
+			return count + Math.ceil(line.length / columns);
+		}, 0);
+		const lines = bufferHeight + 1 + (lastMessageWasRaw ? 1 : 0) + (clearTitle ? 3 : 0);
+		output.write(erase.lines(lines));
+	};
+
+	return {
+		message(msg: string, mopts?: TaskLogMessageOptions) {
+			clear(false);
+			if (mopts?.raw) {
+				buffer += msg;
+			} else {
+				if (lastMessageWasRaw) {
+					buffer += '\n';
+				}
+				buffer += `${msg}\n`;
+			}
+			lastMessageWasRaw = mopts?.raw === true;
+			if (opts.limit !== undefined) {
+				const lines = buffer.split('\n');
+				const linesToRemove = lines.length - opts.limit - (lastMessageWasRaw ? 0 : 1);
+				if (linesToRemove > 0) {
+					lines.splice(0, linesToRemove);
+				}
+				buffer = lines.join('\n');
+			}
+			log.message(lastMessageWasRaw ? `${buffer}\n` : buffer, {
+				output,
+				secondarySymbol,
+				symbol: secondarySymbol,
+				spacing: 0,
+			});
+		},
+		error(message: string): void {
+			clear(true);
+			log.error(message, { output, secondarySymbol, spacing: 1 });
+			log.message(buffer, { output, secondarySymbol, symbol: secondarySymbol, spacing: 1 });
+		},
+		success(message: string): void {
+			clear(true);
+			log.success(message, { output, secondarySymbol, spacing: 1 });
+		},
+	};
+};
path: packages/prompts/src/index.ts
user: login: AdrianGonz97
body: > does that make sense? something like `retainLogOnError: true` but a better name

makes perfect sense!
created_at: 2025-04-09T15:28:02Z
author_association: NONE
diff_hunk: @@ -679,16 +686,38 @@ export const outro = (message = '', opts?: CommonOptions) => {
 
 export interface LogMessageOptions extends CommonOptions {
 	symbol?: string;
+	spacing?: number;
+	secondarySymbol?: string;
 }
 export const log = {
 	message: (
-		message = '',
-		{ symbol = color.gray(S_BAR), output = process.stdout }: LogMessageOptions = {}
+		message: string | string[] = [],
+		{
+			symbol = color.gray(S_BAR),
+			secondarySymbol = color.gray(S_BAR),
+			output = process.stdout,
+			spacing = 1,
+		}: LogMessageOptions = {}
 	) => {
-		const parts = [`${color.gray(S_BAR)}`];
-		if (message) {
-			const [firstLine, ...lines] = message.split('\n');
-			parts.push(`${symbol}  ${firstLine}`, ...lines.map((ln) => `${color.gray(S_BAR)}  ${ln}`));
+		const parts: string[] = [];
+		for (let i = 0; i < spacing; i++) {
+			parts.push(`${secondarySymbol}`);
+		}
+		const messageParts = Array.isArray(message) ? message : message.split('\n');
+		if (message && messageParts.length > 0) {
path: packages/prompts/src/index.ts
user: login: AdrianGonz97
body: not needed
```suggestion
		if (messageParts.length > 0) {
```
created_at: 2025-04-10T01:35:53Z
author_association: NONE
diff_hunk: @@ -995,3 +1024,113 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	title: string;
+	limit?: number;
+	spacing?: number;
+	retainLog?: boolean;
+}
+
+export interface TaskLogMessageOptions {
+	raw?: boolean;
+}
+
+export interface TaskLogCompletionOptions {
+	renderLog?: boolean;
+}
+
+/**
+ * Renders a log which clears on success and remains on failure
+ */
+export const taskLog = (opts: TaskLogOptions) => {
+	const output: Writable = opts.output ?? process.stdout;
+	const columns = getColumns(output);
+	const secondarySymbol = color.dim(S_BAR);
+	const spacing = opts.spacing ?? 1;
+	const barSize = 3;
+	const retainLog = opts.retainLog === true;
+
+	output.write(`${secondarySymbol}\n`);
+	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.title}\n`);
+	for (let i = 0; i < spacing; i++) {
+		output.write(`${secondarySymbol}\n`);
+	}
+
+	let buffer = '';
+	let fullBuffer = '';
+	let lastMessageWasRaw = false;
+
+	const clear = (clearTitle: boolean): void => {
+		if (buffer.length === 0) {
+			return;
+		}
+		const bufferHeight = buffer.split('\n').reduce((count, line) => {
+			if (line === '') {
+				return count + 1;
+			}
+			return count + Math.ceil((line.length + barSize) / columns);
+		}, 0);
+		const lines = bufferHeight + 1 + (clearTitle ? spacing + 2 : 0);
+		output.write(erase.lines(lines));
+	};
+	const renderBuffer = (): void => {
+		if (retainLog === true && fullBuffer.length > 0) {
+			log.message(`${fullBuffer}\n${buffer}`, {
+				output,
+				secondarySymbol,
+				symbol: secondarySymbol,
+				spacing,
+			});
+		} else {
+			log.message(buffer, { output, secondarySymbol, symbol: secondarySymbol, spacing });
+		}
+	};
path: packages/prompts/src/index.ts
user: login: AdrianGonz97
body: adds `print` so that each line is properly colored
```suggestion
	const renderBuffer = (): void => {
		if (retainLog === true && fullBuffer.length > 0) {
			print(`${fullBuffer}\n${buffer}`);
		} else {
			print(buffer);
		}
	};
	const print = (msg: string): void => {
		const colored = msg.split('\n').map(color.dim).join('\n');
		log.message(colored, { output, secondarySymbol, symbol: secondarySymbol, spacing });
	};
```
created_at: 2025-04-10T01:37:25Z
author_association: NONE
diff_hunk: @@ -995,3 +1024,113 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	title: string;
+	limit?: number;
+	spacing?: number;
+	retainLog?: boolean;
+}
+
+export interface TaskLogMessageOptions {
+	raw?: boolean;
+}
+
+export interface TaskLogCompletionOptions {
+	renderLog?: boolean;
+}
+
+/**
+ * Renders a log which clears on success and remains on failure
+ */
+export const taskLog = (opts: TaskLogOptions) => {
+	const output: Writable = opts.output ?? process.stdout;
+	const columns = getColumns(output);
+	const secondarySymbol = color.dim(S_BAR);
+	const spacing = opts.spacing ?? 1;
+	const barSize = 3;
+	const retainLog = opts.retainLog === true;
+
+	output.write(`${secondarySymbol}\n`);
+	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.title}\n`);
+	for (let i = 0; i < spacing; i++) {
+		output.write(`${secondarySymbol}\n`);
+	}
+
+	let buffer = '';
+	let fullBuffer = '';
+	let lastMessageWasRaw = false;
+
+	const clear = (clearTitle: boolean): void => {
+		if (buffer.length === 0) {
+			return;
+		}
+		const bufferHeight = buffer.split('\n').reduce((count, line) => {
+			if (line === '') {
+				return count + 1;
+			}
+			return count + Math.ceil((line.length + barSize) / columns);
+		}, 0);
+		const lines = bufferHeight + 1 + (clearTitle ? spacing + 2 : 0);
+		output.write(erase.lines(lines));
+	};
+	const renderBuffer = (): void => {
+		if (retainLog === true && fullBuffer.length > 0) {
+			log.message(`${fullBuffer}\n${buffer}`, {
+				output,
+				secondarySymbol,
+				symbol: secondarySymbol,
+				spacing,
+			});
+		} else {
+			log.message(buffer, { output, secondarySymbol, symbol: secondarySymbol, spacing });
+		}
+	};
+
+	return {
+		message(msg: string, mopts?: TaskLogMessageOptions) {
+			clear(false);
+			const dimMessage = color.dim(msg);
+			if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer !== '') {
+				buffer += '\n';
+			}
+			buffer += dimMessage;
+			lastMessageWasRaw = mopts?.raw === true;
path: packages/prompts/src/index.ts
user: login: AdrianGonz97
body: coloring is covered by `print`
```suggestion
			clear(false);
			if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer !== '') {
				buffer += '\n';
			}
			buffer += msg;
			lastMessageWasRaw = mopts?.raw === true;
```
created_at: 2025-04-10T01:37:27Z
author_association: NONE
diff_hunk: @@ -995,3 +1024,113 @@ export const tasks = async (tasks: Task[], opts?: CommonOptions) => {
 		s.stop(result || task.title);
 	}
 };
+
+export interface TaskLogOptions extends CommonOptions {
+	title: string;
+	limit?: number;
+	spacing?: number;
+	retainLog?: boolean;
+}
+
+export interface TaskLogMessageOptions {
+	raw?: boolean;
+}
+
+export interface TaskLogCompletionOptions {
+	renderLog?: boolean;
+}
+
+/**
+ * Renders a log which clears on success and remains on failure
+ */
+export const taskLog = (opts: TaskLogOptions) => {
+	const output: Writable = opts.output ?? process.stdout;
+	const columns = getColumns(output);
+	const secondarySymbol = color.dim(S_BAR);
+	const spacing = opts.spacing ?? 1;
+	const barSize = 3;
+	const retainLog = opts.retainLog === true;
+
+	output.write(`${secondarySymbol}\n`);
+	output.write(`${color.green(S_STEP_SUBMIT)}  ${opts.title}\n`);
+	for (let i = 0; i < spacing; i++) {
+		output.write(`${secondarySymbol}\n`);
+	}
+
+	let buffer = '';
+	let fullBuffer = '';
+	let lastMessageWasRaw = false;
+
+	const clear = (clearTitle: boolean): void => {
+		if (buffer.length === 0) {
+			return;
+		}
+		const bufferHeight = buffer.split('\n').reduce((count, line) => {
+			if (line === '') {
+				return count + 1;
+			}
+			return count + Math.ceil((line.length + barSize) / columns);
+		}, 0);
+		const lines = bufferHeight + 1 + (clearTitle ? spacing + 2 : 0);
+		output.write(erase.lines(lines));
+	};
+	const renderBuffer = (): void => {
+		if (retainLog === true && fullBuffer.length > 0) {
+			log.message(`${fullBuffer}\n${buffer}`, {
+				output,
+				secondarySymbol,
+				symbol: secondarySymbol,
+				spacing,
+			});
+		} else {
+			log.message(buffer, { output, secondarySymbol, symbol: secondarySymbol, spacing });
+		}
+	};
+
+	return {
+		message(msg: string, mopts?: TaskLogMessageOptions) {
+			clear(false);
+			const dimMessage = color.dim(msg);
+			if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer !== '') {
+				buffer += '\n';
+			}
+			buffer += dimMessage;
+			lastMessageWasRaw = mopts?.raw === true;
+			if (opts.limit !== undefined) {
+				const lines = buffer.split('\n');
+				const linesToRemove = lines.length - opts.limit;
+				if (linesToRemove > 0) {
+					const removedLines = lines.splice(0, linesToRemove);
+					if (retainLog) {
+						fullBuffer += (fullBuffer === '' ? '' : '\n') + removedLines.join('\n');
+					}
+				}
+				buffer = lines.join('\n');
+			}
+			log.message(buffer, {
+				output,
+				secondarySymbol,
+				symbol: secondarySymbol,
+				spacing: 0,
+			});
path: packages/prompts/src/index.ts
user: login: AdrianGonz97
body: ```suggestion
			print(buffer);
```
created_at: 2025-04-10T01:39:16Z
author_association: NONE
diff_hunk: @@ -679,16 +686,38 @@ export const outro = (message = '', opts?: CommonOptions) => {
 
 export interface LogMessageOptions extends CommonOptions {
 	symbol?: string;
+	spacing?: number;
+	secondarySymbol?: string;
 }
 export const log = {
 	message: (
-		message = '',
-		{ symbol = color.gray(S_BAR), output = process.stdout }: LogMessageOptions = {}
+		message: string | string[] = [],
+		{
+			symbol = color.gray(S_BAR),
+			secondarySymbol = color.gray(S_BAR),
+			output = process.stdout,
+			spacing = 1,
+		}: LogMessageOptions = {}
 	) => {
-		const parts = [`${color.gray(S_BAR)}`];
-		if (message) {
-			const [firstLine, ...lines] = message.split('\n');
-			parts.push(`${symbol}  ${firstLine}`, ...lines.map((ln) => `${color.gray(S_BAR)}  ${ln}`));
+		const parts: string[] = [];
+		for (let i = 0; i < spacing; i++) {
+			parts.push(`${secondarySymbol}`);
+		}
+		const messageParts = Array.isArray(message) ? message : message.split('\n');
+		if (message && messageParts.length > 0) {
path: packages/prompts/src/index.ts
user: login: 43081j
body: this one is here so when you `message('')`, it won't render blank lines

edit: you're right as the later condition will catch it 👍 

edit: actually no, this was preventing empty messages from being rendered. but i suppose we don't need to do that
created_at: 2025-04-10T08:56:31Z
author_association: COLLABORATOR
diff_hunk: @@ -1050,6 +1050,7 @@ export const taskLog = (opts: TaskLogOptions) => {
 	const spacing = opts.spacing ?? 1;
 	const barSize = 3;
 	const retainLog = opts.retainLog === true;
+	const isCI = process.env.CI === 'true';
path: packages/prompts/src/index.ts
user: login: manuel3108
body: thinking out loud, I'm wondering if `process.stdout.isTTY` would be more appropriate here as something to check. According to the [docs](https://nodejs.org/api/tty.html) that should also work for stuff like `cat`.

But I would need to check to be sure
created_at: 2025-04-13T14:32:30Z
author_association: NONE
diff_hunk: @@ -1050,6 +1050,7 @@ export const taskLog = (opts: TaskLogOptions) => {
 	const spacing = opts.spacing ?? 1;
 	const barSize = 3;
 	const retainLog = opts.retainLog === true;
+	const isCI = process.env.CI === 'true';
path: packages/prompts/src/index.ts
user: login: 43081j
body: there was a reason for not using `isTTY` i think, before i got involved with clack

maybe @natemoo-re can remember 🤔 

either way, i think we should leave this as-is for now because we use the same check in other places (e.g. the spinner). but once/if we decide, we can change both in a follow up pr
created_at: 2025-04-13T14:48:05Z
author_association: COLLABORATOR
diff_hunk: @@ -1050,6 +1050,7 @@ export const taskLog = (opts: TaskLogOptions) => {
 	const spacing = opts.spacing ?? 1;
 	const barSize = 3;
 	const retainLog = opts.retainLog === true;
+	const isCI = process.env.CI === 'true';
path: packages/prompts/src/index.ts
user: login: manuel3108
body: oh, ok. Didn't know that!
created_at: 2025-04-13T15:21:55Z
author_association: NONE
diff_hunk: @@ -1050,6 +1050,7 @@ export const taskLog = (opts: TaskLogOptions) => {
 	const spacing = opts.spacing ?? 1;
 	const barSize = 3;
 	const retainLog = opts.retainLog === true;
+	const isCI = process.env.CI === 'true';
path: packages/prompts/src/index.ts
user: login: 43081j
body: opened #286  to tackle this separately 
created_at: 2025-04-14T08:49:04Z
author_association: COLLABORATOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-04-06T18:54:05Z
body: ###  🦋  Changeset detected

Latest commit: e41b36bfe9a08a20e971f40151b71fbf8449cc8d

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name           | Type  |
| -------------- | ----- |
| @clack/prompts | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/bombshell-dev/clack/new/tasklog?filename=.changeset/gorgeous-hotels-listen.md&value=---%0A%22%40clack%2Fprompts%22%3A%20patch%0A---%0A%0Afeat%3A%20add%20taskLog%20prompt%0A)


author_association: NONE
user: login: pkg-pr-new[bot]
created_at: 2025-04-06T18:54:32Z
body: 
[@example/basic](https://pkg.pr.new/template/8e47f39f-1280-4c90-be7d-bec2e51a4140) • [@example/changesets](https://pkg.pr.new/template/bfb61a40-fa78-459e-9133-59d58b260713)


```
npm i https://pkg.pr.new/bombshell-dev/clack/@clack/core@276
```


```
npm i https://pkg.pr.new/bombshell-dev/clack/@clack/prompts@276
```


_commit: <a href="https://github.com/bombshell-dev/clack/runs/40461147500"><code>e41b36b</code></a>_

author_association: NONE
user: login: 43081j
created_at: 2025-04-06T20:05:59Z
body: Ah I may have to tweak it a little

I feel like I remember chunks from a stream not always being line based for stdout. So we probably need to join them up as a string and split again, rather than assuming each one is a line 

I'll take a look. That doesn't fix your problem but I noticed it when reading the usage you linked
author_association: COLLABORATOR
user: login: manuel3108
created_at: 2025-04-06T20:09:14Z
body: Actually, I think that could exactly be the problem I'm experiencing.
author_association: NONE
user: login: 43081j
created_at: 2025-04-06T20:20:48Z
body: We already export `cancel` actually too. I forgot we had that, so we don't need to change it 👍
author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-07T18:24:44Z
body: @manuel3108 (and maybe @natemoo-re review):

I've added a `raw` option:

```ts
log.message('line 0', {raw: true});
log.message('still line 0', {raw: true});
log.message('\nline 1', {raw: true}); // now we render another line because we have an explicit `\n`
```

when it is `false`, every `message` call is treated as an individual line of text
author_association: COLLABORATOR
user: login: manuel3108
created_at: 2025-04-07T18:39:22Z
body: Sound's logical, but sadly I'm still getting the same error. Here is a repro: https://stackblitz.com/edit/node-edenh16x?file=index.js
author_association: NONE
user: login: 43081j
created_at: 2025-04-07T19:08:08Z
body: that is most likely because the event handlers of `stdout` can receive a `Buffer`

if you call `toString()` on the data first, it should work

though the clearing seems a little buggy since some lines don't get cleared which i'd expect to have been
author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-08T13:50:56Z
body: @manuel3108 i fixed the bug which was causing leftover buffer lines

your stackblitz works now if you `toString` the chunks
author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-09T08:59:46Z
body: > aside from the suggested changes below, it would also be nice if the rendered logs were dimmed with `colors.dim(..)` so that they'd have some nice contrast from the title

this is a bug, it was meant to be `dim` already. so i'll fix that when i get chance 👍 
author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-09T13:37:42Z
body: two updates:

- text is now dim
- there's a `spacing` option to control the spacing between the title and the output
author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-09T21:40:08Z
body: ok @AdrianGonz97 @manuel3108 here's where i ended up:

* a new `retainLog` option to the taskLog prompt
  * if this is `true`, the entire log is retained in case you render the log on completion
* a new `renderLog` option to the `success`/`error` methods
  * if this is `true`, the log is rendered after the success/error message
  * by default, `success` will set this to `false`
  * by default, `error` will set this to `true`
  * if `retainLog` was `true` or `limit` is not set, this will output the full log. otherwise, it will output the limited log

thoughts?

i am still wondering if `retainLog` is actually something like `ignoreLimitOnComplete` 😅  names are tough
author_association: COLLABORATOR
user: login: AdrianGonz97
created_at: 2025-04-09T23:56:30Z
body: @43081j 
i love this!!

> i am still wondering if `retainLog` is actually something like `ignoreLimitOnComplete` 😅 names are tough

i think `retainLog` is perfect

---

the only issue i'm seeing now is that the text dimming is only _partially_ working. 

here it is on failures: ![img](https://i.imgur.com/0la1Hsc.png)

it similarly fails during the normal process as well: ![img](https://i.imgur.com/Bhv0sJx.gif)

~~this seemingly has to do with how the `buffer` is derived with the `limit`. im guessing the starting part of the `dim`'s ansi escape code is being chopped off~~ (see the code review)

here's a small repro:
```ts
import { setTimeout } from 'node:timers/promises';
import * as p from '@clack/prompts';

p.intro('some-app');

const t = p.taskLog({ title: 'some title', retainLog: true, limit: 3 });

t.message('foo\nbar\nbaz\nfoobar');
await setTimeout(2000); // small timeout to view it during the regular process
t.error('error!');

p.cancel('ahhh');
```
author_association: NONE
user: login: 43081j
created_at: 2025-04-10T08:56:53Z
body: its basically because in raw mode, a message can span multiple lines

so what you're seeing is

```
<X>|</X> <DIM>message 1
<X>|</X>. still message 1</DIM>
```

from `'message 1\nstill message 1'`


author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-10T09:43:13Z
body: i had to modify what you had a little since we need the `spacing` to be different

but this should all be good now.

i've also renamed the `renderLog` option to `showLog`, which feels a bit nicer
author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-10T20:36:04Z
body: @AdrianGonz97 @manuel3108 hows things looking on your end?

if all is good, im happy to merge this once i get an approval
author_association: COLLABORATOR
user: login: AdrianGonz97
created_at: 2025-04-10T20:49:39Z
body: For the `taskLog`, this is now working perfectly, thank you!! 

The only thing left that we're missing is a replacement for our [`box`](https://github.com/sveltejs/cli/blob/c94db33f88b16cb32602638d7466c2616e89429b/packages/clack-prompts/index.ts#L605), which is effectively a `note` but without the dimming. Not sure if it's solvable in userland, but I'll give it a try in a few.
author_association: NONE
user: login: 43081j
created_at: 2025-04-10T20:58:51Z
body: you could probably just `msg.split('\n').map(color.dim).join('\n')` essentially, and pass that into `note`

but maybe it is worth us having some kind of option for this 🤔  though im not sure `dimmed` makes sense as maybe you want it any colour, or any format. not sure where we'd draw the line there, e.g. is it just a `format: (line) => string` option
author_association: COLLABORATOR
user: login: AdrianGonz97
created_at: 2025-04-10T21:16:39Z
body: > you could probably just `msg.split('\n').map(color.dim).join('\n')` essentially, and pass that into `note`

tried `p.note(msg.split('\n').map(color.reset).join('\n'), "some title");` but ended up with some weirdness where the last line gets partially dimmed 😅
![img](https://i.imgur.com/GGlej0p.png)

> not sure where we'd draw the line there, e.g. is it just a format: (line) => string option

a `format` option would be lovely!



author_association: NONE
user: login: 43081j
created_at: 2025-04-11T08:45:16Z
body: no worries i think this is just because we're hackily putting it into a `dim(msg)` overall

ill open a separate PR to make that configurable some way
author_association: COLLABORATOR
user: login: manuel3108
created_at: 2025-04-11T15:11:02Z
body: Just tried this out in the draft PR for `sv`, and this looks perfect. Thank you so much for your help!

I think the new `format` option for `note` should be the final missing piece to get exactly the same functionality as we have right now. But as you said, that can happen separately.
author_association: NONE
user: login: 43081j
created_at: 2025-04-11T15:39:15Z
body: I already opened #284 for that FYI 

@natemoo-re could I get your review at some point? Once this is approved I will merge 
author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-12T20:18:07Z
body: its not necessarily from a sub-process, keep in mind

we may use it just for a windowed log from some arbitrary stream
author_association: COLLABORATOR
user: login: 43081j
created_at: 2025-04-13T11:55:43Z
body: i pushed an update FYI

i noticed in CI each `limit` window gets logged since its just a text log and isn't a TTY (so can't erase/move)

so i've updated `message` to render nothing in CI. this way, when you call `error` or `success`, we will render the whole log (assuming you enabled that option).
author_association: COLLABORATOR