Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .llm/skills/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Agent Skills ([SKILL.md format](https://agentskills.io)) for specific tasks. Inv
| [context-aware-completion](./context-aware-completion/SKILL.md) | Register context-aware commands with CommandDefinition, gate execution by CommandExecutionContexts, and attach CommandCompletionProvider argument completion (staged providers, TryComplete, TerminalUI token cycling) in DxCommandTerminal. Use when writing commands that need argument completion or execution-context gating, wiring chained completions like item name then item action, or debugging why a command does not run or complete. |
| [custom-argument-parsing](./custom-argument-parsing/SKILL.md) | Parse CommandArg values with TryGet<T>, register custom CommandArgParser functions, and control input cleaning, delimiters, and quote handling sets. Use when writing command handlers that read arguments, adding parsers for new types (e.g. JSON), or fixing "failed to parse" behavior. |
| [input-system-integration](./input-system-integration/SKILL.md) | Wire DxCommandTerminal input - configurable keyboard hotkeys, Unity's new Input System / PlayerInput bindings, the HandlePrevious/HandleNext/ToggleSmall/ToggleFull/CompleteCommand/EnterCommand messages, and input precedence. Use when adding or changing terminal keybindings, PlayerInput wiring, or fixing input handling bugs (including WebGL). |
| [log-echo-contract](./log-echo-contract/SKILL.md) | Preserve the DxCommandTerminal log echo contract - both console surfaces write the typed line into Terminal.Buffer as TerminalLogType.Input before a command handler runs, so a handler that reads the log sees its own echo as the newest entry. Covers OutputLength, why a fixed -1/-2 offset is wrong, why the palette and the copy commands filter differently, why the log view must still SHOW echoes, and the test-helper trap that hides all of it. Use when writing or reviewing a command that reads the log, when a command returns its own name or a command line instead of a message, when touching trace/copy-last/copy-log/CommandPaletteUI.CollectOutput, or when a log-reading test passes against code the product never runs. |
| [register-terminal-command](./register-terminal-command/SKILL.md) | Register terminal commands in DxCommandTerminal via RegisterCommandAttribute or Terminal.Shell.AddCommand, including name inference, arg count bounds, hint text, editor/development-only flags, and ignoring built-in commands. Use when adding console commands/cheats, changing command signatures, debugging why a command is not recognized, or extending the typed builder and argument-spec internals. |
| [run-terminal-tests](./run-terminal-tests/SKILL.md) | Write and run DxCommandTerminal PlayMode tests (Unity Test Runner, Tests/Runtime/, NUnit) following the repo's test style for CommandArg, CommandShell, and Terminal behavior. Use when adding tests, running the test suite, or debugging failing terminal tests. |
| [session-facade](./session-facade/SKILL.md) | Explain and preserve the DxCommandTerminal backend session nullability contract - TerminalSession.Current is never null, the Terminal facade getters (Buffer/Shell/History/AutoComplete) read null before bootstrap and after play-session reset, TerminalUI and CommandPaletteUI apply or bootstrap the session on enable, and command registration stays deferred to first use. Use when touching TerminalUI/CommandPaletteUI lifecycle, adding session consumers, writing bootstrap code, reviewing null-chain questions on Terminal accessors, or debugging why Terminal.Log or Terminal.Shell is null. |
Expand Down
108 changes: 108 additions & 0 deletions .llm/skills/log-echo-contract/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
name: log-echo-contract
description: Preserve the DxCommandTerminal log echo contract - both console surfaces write the typed line into Terminal.Buffer as TerminalLogType.Input before a command handler runs, so a handler that reads the log sees its own echo as the newest entry. Covers OutputLength, why a fixed -1/-2 offset is wrong, why the palette and the copy commands filter differently, why the log view must still SHOW echoes, and the test-helper trap that hides all of it. Use when writing or reviewing a command that reads the log, when a command returns its own name or a command line instead of a message, when touching trace/copy-last/copy-log/CommandPaletteUI.CollectOutput, or when a log-reading test passes against code the product never runs.
metadata:
category: Feature
---

# The Log Echo Contract

## The rule

Both console surfaces echo the line a developer typed into the log BEFORE the
command handler is reached:

| Surface | Site |
| --- | --- |
| `TerminalUI.EnterCommand` | `Runtime/CommandTerminal/UI/TerminalUI.cs` - `Terminal.Log(TerminalLogType.Input, commandText)` then `RunCommand` |
| `CommandPaletteUI` submit | `Runtime/CommandTerminal/UI/CommandPaletteUI.cs` - same order |

So **any code running inside a command handler that reads "the newest entry" is
reading the echo of the command that is running.** `copy-last` returned the
literal text `copy-last`. The palette's own echo at submit time is in its
output window for the same reason.

`TerminalLogType.Input` has exactly two writers, both one per submit. Anything
else that logs is output, whatever its type.

## Read the log through OutputLength, never a fixed offset

`BuiltinCommands.OutputLength(window, logCount)` returns how much of the window
is not trailing command echoes, walking back from the newest end.

```csharp
int logCount = ReadLogWindow(buffer, out LogItem[] window);
int usable = OutputLength(window, logCount); // echoes are window[usable..logCount)
int first = Math.Max(0, usable - Math.Min(lineCount, usable));
```

Why a fixed offset is wrong twice over:

- `logCount - 1` is the echo, always, from a console.
- `logCount - 2` is the message before it ONLY when the command above this one
said something. A command that produced no output leaves its own echo there,
so `trace` printed a command line. And dispatched programmatically
(`shell.RunCommand("trace")`) there is no echo, so the fixed offset skipped
the newest message.

`OutputLength` returns 0 for an all-echo window, so `usable - 1 == -1` is a
clean "nothing before the echo" sentinel.

## Two valid filter shapes, and why they differ

| Consumer | Window | Shape | Why |
| --- | --- | --- | --- |
| `BuiltinCommands` copy and trace | the whole buffer | trailing-echo walk (`OutputLength`) | an echo further back is a command the developer ran, and a transcript wants those as much as the output |
| `CommandPaletteUI.CollectOutput` | this run only, from a `Version` delta captured before the echo | whole-window `type == Input` skip | the window starts at the version delta, so it cannot contain another submit's echo |

Neither is a missed case. Pick by what the window means, and say which in a
comment. Do not unify them.

## The log view must SHOW echoes

`TerminalUI.RefreshLogs` renders every entry, echo included, and colors it via
the `--text-input-echo` class. That is correct: the developer typed it, and it
belongs on screen. The contract above is about reading the log programmatically
for a result, not about what is displayed. Do not "fix" the view by filtering
echoes out.

## The test-helper trap (this is how the bug survived two review rounds)

A test helper that calls `shell.RunCommand(line)` directly exercises a path the
product never takes, because the product echoes first. Every log-reading test
then passes against code that is broken in the console.

```csharp
// WRONG: no echo is ever written, so a copy bug is invisible.
_shell.RunCommand(line);

// RIGHT: mirrors EnterCommand's order.
Terminal.Log(TerminalLogType.Input, line);
_shell.RunCommand(line);
```

When a handler reads the log, its test helper owes the echo. When it does not,
the plain dispatch is fine - but the difference has to be a decision, not an
accident.

Symptom to recognise: a test that asserts the copied text equals the message
above, and passes, while the command returns its own name in the console.

## Sweep checklist

When a command starts reading the log:

1. Grep `Terminal.Buffer`, `CommandLog`, `CopyTo`, `Logs`, `LogItem` in the
handler. Any hit needs `OutputLength` or an explicit type filter.
2. Grep for `- 1`, `- 2`, "second newest", "previous message" near a log read.
3. Re-read the test helper. Does it echo?
4. Check the other two consumers still agree: `CommandTrace`,
`CommandPaletteUI.CollectOutput`, `TerminalUI.RefreshLogs`.

## Related

- [session-facade](../session-facade/SKILL.md) - the nullability boundary on
`Terminal.Buffer` itself. This skill is about what is IN the buffer once it is
non-null during dispatch.
- [register-terminal-command](../register-terminal-command/SKILL.md) - how the
handler gets here.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- `copy-last` and `copy-log [n]` put the console log on the system clipboard: the newest line, or the last N, joined with newlines. Until now nothing could be read out of the console at all - the log view is the only place a message, a command echo, or a failing value ever appears, and none of it could be selected, so a developer who saw `Command 'give' threw ArgumentException` in a device build had to retype it or screenshot the game window. Both surfaces echo the line you typed into the log before the command runs, so a copy skips the echoes at the newest end: `copy-last` returns the line you were reading, not the word `copy-last`. An echo further back stays, because a transcript of a session wants the commands in it as much as the output. A copied line is the line the log shows, so the stack trace is not included (`trace` is how you read one) and copying 256 lines does not carry 256 traces. A clipboard write is a request a platform can decline - tvOS has none, and a browser may refuse without a user gesture - so the write is read back and compared, and a copy that did not happen says so instead of silently doing nothing. An empty log, a log holding only commands, a count that is not a number, and a count below one are each reported rather than ignored.
- The log scrolls with the keyboard while the command line holds focus. Page Up and Page Down page it by one viewport, and Ctrl+Home (Cmd+Home on macOS) and Ctrl+End reach its oldest and newest lines, so a developer looking for one error in a full 256-entry buffer no longer has to drag a 10px scrollbar. Home and End without the modifier stay with the command line, where they move the caret as a text field should, and every other key is untouched. Paging back detaches the tail exactly as scrolling back does, so new output no longer pulls the view to the end while it is being read; Ctrl+End is the way back to following. A page that reaches the end leaves the log following rather than parked a line short of an end that is still growing.
- `CommandLog.CopyTo(LogItem[] destination)` copies the log's visible window, oldest first, into a caller-owned array and returns how many entries it wrote. Reading `CommandLog.Logs` counts and then indexes as two separate reads, so a log written from another thread between them can be missed or, if the buffer was cleared or shrunk, throw. One `CopyTo` call is one consistent view. A destination shorter than the window truncates to its oldest entries, so size it for the largest buffer the session configures.
- Paste on both console surfaces: Ctrl+V (Cmd+V on macOS) pastes the system clipboard into the terminal's command line and the quick-launch bar's search input, replacing the current selection. A UI Toolkit text field has no clipboard of its own, so the terminal answers the key. A pasted block arrives as the arguments it reads as - every run of whitespace (newlines from a copied stack trace, tabs from a copied log line, the trailing newline of a copied command) collapses to the single space that separates arguments - and a run at the start of the field is dropped because it separates nothing. A quote still groups, so `set name "two words"` pastes as one argument, but the paste normalizes before the tokenizer sees the text, so quoted whitespace survives typing and not pasting. The clipboard is unavailable on tvOS, and a platform that will not answer without a user gesture may read empty; neither is an error, the key is left alone, and anything the platform does with it still happens. One limit: a control character that is not whitespace stays in the field, where a single line cannot show it, and reaches the argument as the character that was copied.
- `TerminalUI.SetCursorBlinkPaused(bool)` freezes the caret blink schedule with the caret pinned visible (or restores normal blinking), so screenshots, video capture, and tests produce deterministic caret pixels regardless of when the frame lands.
Expand Down Expand Up @@ -42,6 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Changed

- `trace` no longer prints a command line. It read a fixed entry back, which is the console's echo of `trace` itself - so it showed the message before it correctly only when the command above `trace` had said something, and printed that command's echo when it had not. It now reads the newest entry that is not a command echo, which also fixes `trace` dispatched programmatically (`shell.RunCommand("trace")`), where the old fixed offset skipped the newest message.
- `time` no longer drops argument quoting. It rejoined its arguments with single spaces and re-tokenized, so a quoted argument arrived as several: `time set-variable greet "two words"` was rejected with `set-variable requires exactly 2 arguments` before the timed command ran at all. It now dispatches the parsed arguments it already has, so `time` can time anything the console can run. `log` and `log-terminal` are unchanged - they want the flattened form, so `log "a b"` still prints `a b`. A `$variable` is now substituted once rather than twice, so a stored value that is itself a variable reference is no longer expanded again.
- The shipped documentation now matches the package. The README pointed at a directory that no longer exists, named a component and two inspector options that are not the ones it meant, and listed four argument types as "planned" that have shipped. `doc.md` documented four APIs whose real signatures take required arguments or do not exist at all. The completion guide promised layer and tag completion from an adapter whose constructor throws for those types. The themes and fonts guide was reachable only through the documentation sidebar.
- Every character is typeable in a command line and in a palette search bar. A keyboard hotkey bound to a character key is now left to the field being typed into: with the defaults `` ` `` and `` #` ``, pressing the console key while the terminal is open types a backtick or a tilde instead of closing the terminal (and the character used to be dropped from the line as well), and typing the console key in a palette search no longer closes the palette and opens the terminal behind it. Bindings that press no character are unchanged and keep working while you type: navigation and editing keys, function keys, modifiers, lock and media keys, mouse or joystick buttons, and any `ctrl+` chord. Escape still closes an open terminal; a `closeHotkey` bound to a character cannot. PlayerInput bindings are unaffected and still fire while a field has focus, because the keyboard controller reads the binding string rather than the key an action pressed.
- First command readiness is faster in projects with many loaded assemblies: the discovery scan no longer re-reads every loaded assembly's metadata on each registration cycle — that per-assembly check is immutable, so it is now cached for the assembly's lifetime. Discovered commands, their order, and the discovery filters' decisions are unchanged.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,28 @@ The clipboard is unavailable on tvOS, and a platform that will not answer withou

One limit: a control character that is not whitespace is left in the field. A single line cannot show it, so it is invisible while you are typing, and it reaches the argument as the exact character you copied. Dropping it would lose your text; escaping it there would change the command. A command that prints it gets the visible escape from the log.

## Copying out of the console

The log view is the only place a message, a command echo, or a failing value ever appears, so two commands put it on the system clipboard:

- `copy-last` copies the newest line.
- `copy-log` copies every buffered line; `copy-log 20` copies the last 20.

A copied line is the line the log shows, so the stack trace is not included - `trace` is how you read one, and copying 256 lines does not carry 256 traces. Each command answers in the console like every other one: an empty log, a log with no text in it, a count that is not a number, and a count below one are each reported rather than ignored.

A clipboard write is a request a platform can decline. tvOS has no clipboard, and a browser clipboard may refuse without a user gesture, so the write is read back and compared and a copy that did not happen says so instead of silently doing nothing. The read-back is not an acknowledgement: copying the same window twice reports success whether or not the second write landed, and a browser that accepts the write asynchronously can read back the old value.

## Reading the log with the keyboard

The command line holds panel focus for as long as the console is open, so the terminal routes these to the log itself:

- **Page Up / Page Down** page the log by one viewport.
- **Ctrl+Home** and **Ctrl+End** (Cmd on macOS) jump to its oldest and newest lines.

Home and End without the modifier stay with the command line, where they move the caret as a text field should. Every other key is untouched - typing, history recall, completion, and closing work exactly as they did.

Paging back detaches the tail exactly as scrolling back does, so new output does not pull the view to the end while you are reading. Ctrl+End is the way back to following. A log that has not been laid out - a headless editor, or a view with no rendered Game view - has nothing to page through and stays where it is.

## Typing wins over a character binding

A binding that presses a character key is left to the field being typed into. While the command line or the palette search bar has focus, pressing the key types the character and does not run the binding - so with the defaults `` ` `` (toggle) and `` #` `` (full), those characters are typeable and the console key no longer closes an open console.
Expand Down
Loading
Loading