@markusylisiurunen/tau 0.1.47 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +15 -0
  2. package/dist/app.js +148 -28
  3. package/dist/app.js.map +1 -1
  4. package/dist/cli.js +16 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands.js +21 -5
  7. package/dist/commands.js.map +1 -1
  8. package/dist/content_loader.js +67 -6
  9. package/dist/content_loader.js.map +1 -1
  10. package/dist/export/engine_history.js +57 -0
  11. package/dist/export/engine_history.js.map +1 -0
  12. package/dist/export/html.js +329 -0
  13. package/dist/export/html.js.map +1 -0
  14. package/dist/export/index.js +11 -0
  15. package/dist/export/index.js.map +1 -0
  16. package/dist/export/types.js +2 -0
  17. package/dist/export/types.js.map +1 -0
  18. package/dist/main.js +1 -0
  19. package/dist/main.js.map +1 -1
  20. package/dist/personas.js +12 -1
  21. package/dist/personas.js.map +1 -1
  22. package/dist/tools/registry.js +2 -2
  23. package/dist/tools/registry.js.map +1 -1
  24. package/dist/types.js.map +1 -1
  25. package/dist/ui/bash_execution.js +4 -5
  26. package/dist/ui/bash_execution.js.map +1 -1
  27. package/dist/ui/file_execution.js +7 -9
  28. package/dist/ui/file_execution.js.map +1 -1
  29. package/dist/ui/restricted_execution.js +20 -31
  30. package/dist/ui/restricted_execution.js.map +1 -1
  31. package/dist/ui/slash_autocomplete.js +17 -5
  32. package/dist/ui/slash_autocomplete.js.map +1 -1
  33. package/dist/ui/task_execution.js +21 -29
  34. package/dist/ui/task_execution.js.map +1 -1
  35. package/dist/ui/theme.js +51 -0
  36. package/dist/ui/theme.js.map +1 -1
  37. package/dist/ui/theme_preview.js +154 -0
  38. package/dist/ui/theme_preview.js.map +1 -0
  39. package/dist/version.js +1 -1
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -57,6 +57,17 @@ npm run build
57
57
  npm start
58
58
  ```
59
59
 
60
+ ## theme preview
61
+
62
+ run a model-free UI preview for theme iteration:
63
+
64
+ ```sh
65
+ tau --theme-preview
66
+ ```
67
+
68
+ theme preview renders a fixed set of UI fixtures and disables model calls so you can tweak colors and spacing
69
+ without asking the model for visible content.
70
+
60
71
  ## risk levels
61
72
 
62
73
  tau uses risk levels to control what the model can do. this lets you stay in control while working alongside AI.
@@ -75,6 +86,8 @@ or change it during a session with `/risk:restricted`, `/risk:read-only`, or `/r
75
86
 
76
87
  the default is read-only because it lets the model investigate your code and answer questions without risk of unintended changes. bump it to read-write when you're ready to let the model make edits.
77
88
 
89
+ custom personas (loaded from disk) only allow `read-only` and `read-write` risk levels. if you try to use `restricted` with a custom persona, tau will keep the risk level at `read-only`.
90
+
78
91
  ## personas
79
92
 
80
93
  tau comes with several built-in personas across different models:
@@ -158,6 +171,7 @@ tau supports slash commands for common actions:
158
171
  | `/new` | clear the session and start fresh |
159
172
  | `/copy` | copy the last assistant message |
160
173
  | `/copy:code` | copy just the code blocks |
174
+ | `/export:html` | export chat history to html |
161
175
  | `/reload` | reload personas, prompts, and skills from disk |
162
176
  | `/compact:only-summary` | compress history and continue with a summary |
163
177
  | `/compact:with-last-turn` | compress history but keep the last exchange |
@@ -258,6 +272,7 @@ you can also set model parameters via optional frontmatter fields:
258
272
  model: claude-haiku-4-5
259
273
  reasoning: medium
260
274
  ```
275
+ - `tools`: list of tool names to enable for this persona. allowed: `bash`, `write`, `edit`, `read`, `list`, `grep`, `task`, `fork`. if omitted, defaults to `bash`, `write`, `edit` (and `task` when subagents are enabled). risk levels still apply.
261
276
 
262
277
  use it with `--persona my-assistant` or `/persona:my-assistant`. if a project persona id conflicts with a user or built-in persona, the project persona wins.
263
278
 
package/dist/app.js CHANGED
@@ -1,4 +1,5 @@
1
- import { homedir } from "node:os";
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
4
  import { streamSimple } from "@mariozechner/pi-ai";
4
5
  import { Spacer, TUI } from "@mariozechner/pi-tui";
@@ -7,6 +8,8 @@ import { copyTextToClipboard } from "./clipboard.js";
7
8
  import { buildHelpText, getRiskLevelDescription, parseCommand } from "./commands.js";
8
9
  import { getApiKeyForProvider } from "./config.js";
9
10
  import { loadAllContent } from "./content_loader.js";
11
+ import { buildExportEntriesFromHistory } from "./export/engine_history.js";
12
+ import { renderExport } from "./export/index.js";
10
13
  import { SessionEngine } from "./session/session_engine.js";
11
14
  import { formatSubagentsForPrompt } from "./subagents/registry.js";
12
15
  import { createAppTerminal } from "./terminal.js";
@@ -30,6 +33,7 @@ import { buildGrepBlockedView, buildGrepFinishedView, buildGrepRunningView, buil
30
33
  import { getFileAutocompleteToken, SlashAutocompleteProvider } from "./ui/slash_autocomplete.js";
31
34
  import { buildTaskBlockedView, buildTaskFinishedView, buildTaskRunningView, } from "./ui/task_execution.js";
32
35
  import { createUiTheme } from "./ui/theme.js";
36
+ import { buildThemePreviewMessages } from "./ui/theme_preview.js";
33
37
  import { buildBaseSystemPrompt, buildEnvironmentTag, buildProjectContextBlock, buildSkillsIndexBlock, findAgentsFilesFromCwdToHome, formatRiskLevelChangeNotice, } from "./utils/context.js";
34
38
  import { formatHistoryForCompression } from "./utils/fork.js";
35
39
  import { formatAdaptiveNumber, formatCwd, formatTokenWindow } from "./utils/format.js";
@@ -82,6 +86,7 @@ export class ChatApp {
82
86
  currentTurnStartedAt;
83
87
  lastTurnDurationMs = 0;
84
88
  turnTimer;
89
+ themePreview;
85
90
  constructor(options) {
86
91
  this.personas = options.personas;
87
92
  this.prompts = options.prompts ?? [];
@@ -91,15 +96,11 @@ export class ChatApp {
91
96
  this.initialUserMessage = options.initialUserMessage;
92
97
  this.config = options.config ?? {};
93
98
  this.compactToolUi = this.config.toolDisplayMode !== "full";
94
- if (options.initialRiskLevel) {
95
- this.riskLevel = options.initialRiskLevel;
99
+ this.themePreview = options.themePreview ?? false;
100
+ this.showThinking = this.themePreview;
101
+ if (this.themePreview) {
102
+ this.queuedUserMessages.push("Queue: adjust muted contrast and preview again", "Queue: verify tool error colors");
96
103
  }
97
- this.initialRiskLevel = this.riskLevel;
98
- this.environmentTag = buildEnvironmentTag({
99
- riskLevel: this.initialRiskLevel,
100
- cwd: process.cwd(),
101
- datetime: new Date().toISOString(),
102
- });
103
104
  this.agentsFiles = options.withContext
104
105
  ? findAgentsFilesFromCwdToHome(process.cwd(), homedir())
105
106
  : [];
@@ -112,6 +113,19 @@ export class ChatApp {
112
113
  this.personas.find((p) => p.id.toLowerCase() === options.initialPersonaId.toLowerCase())) ||
113
114
  this.personas[0];
114
115
  this.clampPersonaReasoning(this.currentPersona);
116
+ if (options.initialRiskLevel) {
117
+ this.riskLevel = options.initialRiskLevel;
118
+ }
119
+ const allowedRiskLevels = this.getAllowedRiskLevelsForPersona(this.currentPersona);
120
+ if (!allowedRiskLevels.includes(this.riskLevel)) {
121
+ this.riskLevel = allowedRiskLevels[0] ?? "read-only";
122
+ }
123
+ this.initialRiskLevel = this.riskLevel;
124
+ this.environmentTag = buildEnvironmentTag({
125
+ riskLevel: this.initialRiskLevel,
126
+ cwd: process.cwd(),
127
+ datetime: new Date().toISOString(),
128
+ });
115
129
  this.baseSystemPrompt = buildBaseSystemPrompt({
116
130
  personaSystemPrompt: this.currentPersona.systemPrompt,
117
131
  skillsBlock: this.getSkillsIndexBlockForPersona(this.currentPersona).skillsBlock,
@@ -139,7 +153,7 @@ export class ChatApp {
139
153
  });
140
154
  this.uiTheme = createUiTheme("ansi");
141
155
  this.ui = new TUI(createAppTerminal());
142
- this.chatContainer = new ChatContainerComponent(this.uiTheme);
156
+ this.chatContainer = new ChatContainerComponent(this.uiTheme, this.showThinking);
143
157
  this.chatContainer.setCompactToolUi(this.compactToolUi);
144
158
  this.footer = new FooterComponent(this.uiTheme, this.ui);
145
159
  this.queuedMessages = new QueuedMessagesComponent(this.uiTheme, this.queuedUserMessages);
@@ -153,12 +167,20 @@ export class ChatApp {
153
167
  this.ui.addChild(this.queuedMessages);
154
168
  this.ui.addChild(this.editor);
155
169
  this.ui.addChild(this.footer);
156
- this.chatContainer.addMessage({
157
- type: "app_intro",
158
- appName: "tau",
159
- version: APP_VERSION,
160
- helpText: buildHelpText(this.agentsFiles, this.skills),
161
- });
170
+ if (this.themePreview) {
171
+ const messages = buildThemePreviewMessages(this.uiTheme);
172
+ for (const message of messages) {
173
+ this.chatContainer.addMessage(message);
174
+ }
175
+ }
176
+ else {
177
+ this.chatContainer.addMessage({
178
+ type: "app_intro",
179
+ appName: "tau",
180
+ version: APP_VERSION,
181
+ helpText: buildHelpText(this.agentsFiles, this.skills, this.getAllowedRiskLevelsForPersona(this.currentPersona)),
182
+ });
183
+ }
162
184
  this.ui.setFocus(this.editor);
163
185
  this.updateFooter();
164
186
  this.updateEditorBorderColor();
@@ -182,6 +204,9 @@ export class ChatApp {
182
204
  };
183
205
  this.editor.onAltUp = () => this.popQueuedUserMessageIntoEditor();
184
206
  this.editor.beforeSubmit = (text) => {
207
+ if (this.themePreview) {
208
+ return false;
209
+ }
185
210
  if (!this.isStreaming)
186
211
  return true;
187
212
  const trimmed = text.trimStart();
@@ -206,7 +231,7 @@ export class ChatApp {
206
231
  this.editor.setAutocompleteProvider(new SlashAutocompleteProvider(() => this.personas.map((p) => ({ id: p.id, label: p.label })), () => this.prompts.map((t) => ({ id: t.id, label: t.label })), () => this.bashCommands.map((b) => ({
207
232
  id: b.id,
208
233
  description: b.description,
209
- })), () => this.projectFiles, () => this.skills.map((skill) => skill.name)));
234
+ })), () => this.projectFiles, () => this.skills.map((skill) => skill.name), () => this.getAllowedRiskLevelsForPersona(this.currentPersona)));
210
235
  this.editor.onSubmit = (text) => this.handleSubmit(text);
211
236
  }
212
237
  getEditorTextBeforeCursor() {
@@ -232,6 +257,9 @@ export class ChatApp {
232
257
  }
233
258
  async start() {
234
259
  this.ui.start();
260
+ if (this.themePreview) {
261
+ return;
262
+ }
235
263
  if (this.initialUserMessage) {
236
264
  await this.sendInitialUserMessage(this.initialUserMessage);
237
265
  }
@@ -271,6 +299,14 @@ export class ChatApp {
271
299
  this.ui.requestRender();
272
300
  }
273
301
  updateEditorHeader(cwd = formatCwd(process.cwd()), personaName = this.currentPersona.label || this.currentPersona.id, reasoningLabel = this.currentPersona.settings.reasoning ?? "none") {
302
+ if (this.themePreview) {
303
+ const labelStyle = this.uiTheme.palette.muted;
304
+ this.editor.setHeader("theme preview", "model disabled", {
305
+ leftStyle: labelStyle,
306
+ rightStyle: labelStyle,
307
+ });
308
+ return;
309
+ }
274
310
  if (this.isBashMode) {
275
311
  this.editor.setHeader("bash", "", { leftStyle: this.editor.borderColor });
276
312
  return;
@@ -332,7 +368,7 @@ export class ChatApp {
332
368
  ? this.getContextWindowForLastTurn(last)
333
369
  : this.currentPersona.model.contextWindow;
334
370
  const { input, read, write, output } = this.getSessionTotals();
335
- const stats = `↑${formatTokenWindow(input)} ↓${formatTokenWindow(output)} cache ${formatTokenWindow(read)}/${formatTokenWindow(write)}`;
371
+ const stats = `↑${formatTokenWindow(input)} ↓${formatTokenWindow(output)} (cache r${formatTokenWindow(read)} w${formatTokenWindow(write)})`;
336
372
  const promptTokensSent = last
337
373
  ? (last.usage?.input ?? 0) + (last.usage?.cacheRead ?? 0) + (last.usage?.cacheWrite ?? 0)
338
374
  : 0;
@@ -449,12 +485,20 @@ export class ChatApp {
449
485
  persona.settings.reasoning = allowed[0];
450
486
  }
451
487
  }
488
+ isCustomPersona(persona) {
489
+ return persona.source !== "builtin";
490
+ }
491
+ getAllowedRiskLevelsForPersona(persona) {
492
+ return this.isCustomPersona(persona)
493
+ ? ["read-only", "read-write"]
494
+ : ["restricted", "read-only", "read-write"];
495
+ }
452
496
  // Risk Level Management ---------------------------------------------------------------
453
497
  cycleRiskLevel() {
454
- const levels = ["restricted", "read-only", "read-write"];
498
+ const levels = this.getAllowedRiskLevelsForPersona(this.currentPersona);
455
499
  const previous = this.riskLevel;
456
500
  const index = levels.indexOf(this.riskLevel);
457
- const next = levels[(index + 1) % levels.length];
501
+ const next = levels[(index + 1) % levels.length] ?? levels[0];
458
502
  this.riskLevel = next;
459
503
  this.engine.setRiskLevel(next);
460
504
  this.updateFooter();
@@ -476,6 +520,13 @@ export class ChatApp {
476
520
  const next = this.personas[(index + 1) % this.personas.length];
477
521
  this.currentPersona = next;
478
522
  this.clampPersonaReasoning(this.currentPersona);
523
+ const allowedRiskLevels = this.getAllowedRiskLevelsForPersona(this.currentPersona);
524
+ if (!allowedRiskLevels.includes(this.riskLevel)) {
525
+ this.setRiskLevel(this.riskLevel, {
526
+ force: true,
527
+ reason: "restricted risk level is not available for custom personas.",
528
+ });
529
+ }
479
530
  const skillsContext = this.getSkillsIndexBlockForPersona(this.currentPersona);
480
531
  this.baseSystemPrompt = buildBaseSystemPrompt({
481
532
  personaSystemPrompt: this.currentPersona.systemPrompt,
@@ -619,6 +670,9 @@ export class ChatApp {
619
670
  }
620
671
  }
621
672
  async handleSubmit(text) {
673
+ if (this.themePreview) {
674
+ return;
675
+ }
622
676
  const trimmed = text.trim();
623
677
  if (!trimmed)
624
678
  return;
@@ -711,6 +765,9 @@ export class ChatApp {
711
765
  case "copyCode":
712
766
  await this.copyLastAssistantCodeBlock();
713
767
  break;
768
+ case "export":
769
+ await this.exportSessionHtml();
770
+ break;
714
771
  case "new":
715
772
  this.clearSession();
716
773
  break;
@@ -741,7 +798,7 @@ export class ChatApp {
741
798
  }
742
799
  }
743
800
  showHelp() {
744
- this.addSystemMessage(buildHelpText(this.agentsFiles, this.skills), "muted");
801
+ this.addSystemMessage(buildHelpText(this.agentsFiles, this.skills, this.getAllowedRiskLevelsForPersona(this.currentPersona)), "muted");
745
802
  }
746
803
  async copyLastAssistantMessage() {
747
804
  const lastAssistant = this.getLastAssistantMessage();
@@ -782,6 +839,31 @@ export class ChatApp {
782
839
  this.addSystemMessage(`clipboard copy failed: ${err.message}`, "error");
783
840
  }
784
841
  }
842
+ async exportSessionHtml() {
843
+ const history = this.engine.history;
844
+ if (history.length === 0) {
845
+ this.addSystemMessage("no conversation to export.", "warn");
846
+ return;
847
+ }
848
+ try {
849
+ const entries = buildExportEntriesFromHistory(history);
850
+ if (entries.length === 0) {
851
+ this.addSystemMessage("no conversation to export.", "warn");
852
+ return;
853
+ }
854
+ const html = renderExport("html", entries, {
855
+ title: "tau chat export",
856
+ generatedAt: Date.now(),
857
+ });
858
+ const dir = await mkdtemp(join(tmpdir(), "tau-export-"));
859
+ const filePath = join(dir, "index.html");
860
+ await writeFile(filePath, html, "utf8");
861
+ this.addSystemMessage(filePath, "muted");
862
+ }
863
+ catch (err) {
864
+ this.addSystemMessage(`export failed: ${err.message}`, "error");
865
+ }
866
+ }
785
867
  async stashEditorToClipboard() {
786
868
  const text = this.editor.getText();
787
869
  if (!text.trim()) {
@@ -1016,21 +1098,45 @@ Write plain prose, no formatting. Be thorough enough that the reader can resume
1016
1098
  void this.drainQueuedUserMessages();
1017
1099
  }
1018
1100
  }
1019
- setRiskLevel(level) {
1101
+ setRiskLevel(level, options) {
1102
+ const allowed = this.getAllowedRiskLevelsForPersona(this.currentPersona);
1103
+ let target = level;
1104
+ let forced = false;
1105
+ if (!allowed.includes(level)) {
1106
+ if (!options?.force) {
1107
+ if (!options?.silent) {
1108
+ this.addSystemMessage(`risk level '${level}' is not available for the current persona. allowed: ${allowed.join(", ")}.`, "error");
1109
+ }
1110
+ return;
1111
+ }
1112
+ target = allowed[0] ?? "read-only";
1113
+ forced = true;
1114
+ }
1020
1115
  const previous = this.riskLevel;
1021
- this.riskLevel = level;
1022
- this.engine.setRiskLevel(level);
1116
+ this.riskLevel = target;
1117
+ this.engine.setRiskLevel(target);
1023
1118
  this.updateFooter();
1024
- if (previous !== level) {
1119
+ if (previous !== target) {
1025
1120
  const from = this.pendingRiskLevelChange?.from ?? previous;
1026
- if (from === level) {
1121
+ if (from === target) {
1027
1122
  this.pendingRiskLevelChange = undefined;
1028
1123
  }
1029
1124
  else {
1030
- this.pendingRiskLevelChange = { from, to: level };
1125
+ this.pendingRiskLevelChange = { from, to: target };
1031
1126
  }
1032
1127
  }
1033
- this.addSystemMessage(this.formatRiskLevelNotice(level), "success");
1128
+ if (options?.silent) {
1129
+ return;
1130
+ }
1131
+ if (forced) {
1132
+ const reason = options?.reason ?? `risk level '${level}' is not available for the current persona.`;
1133
+ const msg = previous === target
1134
+ ? `${reason} staying at ${target}.`
1135
+ : `${reason} switched to ${target}.`;
1136
+ this.addSystemMessage(msg, "warn");
1137
+ return;
1138
+ }
1139
+ this.addSystemMessage(this.formatRiskLevelNotice(target), "success");
1034
1140
  }
1035
1141
  switchPersona(id) {
1036
1142
  const persona = this.personas.find((p) => p.id.toLowerCase() === id.toLowerCase());
@@ -1040,6 +1146,13 @@ Write plain prose, no formatting. Be thorough enough that the reader can resume
1040
1146
  }
1041
1147
  this.currentPersona = persona;
1042
1148
  this.clampPersonaReasoning(this.currentPersona);
1149
+ const allowedRiskLevels = this.getAllowedRiskLevelsForPersona(this.currentPersona);
1150
+ if (!allowedRiskLevels.includes(this.riskLevel)) {
1151
+ this.setRiskLevel(this.riskLevel, {
1152
+ force: true,
1153
+ reason: "restricted risk level is not available for custom personas.",
1154
+ });
1155
+ }
1043
1156
  const skillsContext = this.getSkillsIndexBlockForPersona(this.currentPersona);
1044
1157
  this.baseSystemPrompt = buildBaseSystemPrompt({
1045
1158
  personaSystemPrompt: this.currentPersona.systemPrompt,
@@ -1102,6 +1215,13 @@ Write plain prose, no formatting. Be thorough enough that the reader can resume
1102
1215
  this.clampPersonaReasoning(this.currentPersona);
1103
1216
  this.addSystemMessage(`previous persona no longer available; switched to ${this.currentPersona.label || this.currentPersona.id}.`, "warn");
1104
1217
  }
1218
+ const allowedRiskLevels = this.getAllowedRiskLevelsForPersona(this.currentPersona);
1219
+ if (!allowedRiskLevels.includes(this.riskLevel)) {
1220
+ this.setRiskLevel(this.riskLevel, {
1221
+ force: true,
1222
+ reason: "restricted risk level is not available for custom personas.",
1223
+ });
1224
+ }
1105
1225
  // Rebuild system prompt and update the engine
1106
1226
  const skillsContext = this.getSkillsIndexBlockForPersona(this.currentPersona);
1107
1227
  this.baseSystemPrompt = buildBaseSystemPrompt({