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
16 changes: 16 additions & 0 deletions docs/changes/unreleased/1699-slash-chips-anywhere.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
kind: changed
title: Every recognised slash command is chipped wherever it stands, in the box and the sent transcript
pr: 1699
surface: [chat, docs]
invalidates:
- "A chip marked only a command this surface would act on: a leading command, or a send-door tag (`/standing`, `/orders`, `/task`) elsewhere, while `/compact` mid-sentence stayed plain. A chip now marks any recognised command wherever it stands, and whether enter acts on it is unchanged."
---

A chip is a recognition mark: it says codeaf knows the word, not that enter will
run it. Enter still runs only a leading command, and still acts on a send-door tag
away from the head. A tag backspaced to plain words stays plain in the sent
transcript as it already did in the box, and that includes a message typed while an
answer was still coming, which waits its turn with the tag still plain.
The waiting block, a sentence steered into a running answer and a reopened
conversation keep recognised commands chipped and demoted send-door words plain.
39 changes: 24 additions & 15 deletions internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,24 @@ handling; typing `/` there does not open this composer list.

A command codeaf recognizes is not drawn as ordinary text. `/task`, `/compact`, `/clear`
and the rest get a **chip**: a tinted background behind exactly the letters of the
command — the same tint the *chosen* row of a list wears, the one that marks the model in
use or the conversation you are in — with the accent ink on top.
command — the same tint the *chosen* row in `/resume` wears, marking the
conversation you are in — with the accent ink on top.
No brackets, no border, and nothing added to the line.

It happens in two places: **live in the message box as you type**, and in your message
after it is sent, where it stays for as long as the conversation is scrolled back
through.

Only a command this surface will act on gets one. At the start of the box that is every
recognized command. In the middle or at the end it is one of the two send-door tags,
`/standing` or `/task`, including `/orders`. Other commands there are plain words. A typo
is plain too: `/tsak` stays ordinary text. A path is never chipped, for the reasons in
"Why a file path does not pop up the command list".
A sentence you send into a running answer keeps its chips. A message waiting for
the answer shows them while it waits.

A chip marks any command codeaf recognises, wherever it stands — at the start, in
the middle, or at the end. `/compact`, `/clear`, `/model` and the rest wear it
exactly as a leading command does. It does not mean enter will run that word:
enter acts only on a leading command, and on a send-door tag away from the head
(the next section). A typo is plain: `/tsak` stays ordinary text. A path is never
chipped, for the reasons in "Why a file path does not pop up the command list".
A command glued to punctuation — `/model,` or `(/model)` — is plain, like a typo.

The chip never adds a cell. A leading command runs in its usual form. A live send-door
tag is removed from the words handed through its door, while your transcript keeps the
Expand All @@ -129,12 +134,14 @@ tags** anywhere else in a draft:

Both roads end at something you can see: standing raises its ratification card, and task
starts one worker in the open — its started row, and its row on the roster, where it can be
stopped. A pasted tag does not silently do work, because the chip says what enter will do. With no other words, each tag behaves like that command's existing
stopped. A pasted tag does not silently do work: a live send-door tag is named on
the hint line under the box, and it only ever opens the door you can see. With no other words, each tag behaves like that command's existing
bare form. With two live tags codeaf sends nothing, leaves the draft in the box, and says
`one tag per send — backspace one to make it plain words`.

Other commands remain ordinary prose away from the start. `later I will run /compact on
this` is sent literally, and `/compact` is plain rather than chipped.
this` is sent literally, and codeaf still chips `/compact` there — the mark says the word
is recognised, not that enter will run it.

## Backspace after a slash tag makes it plain words

Expand All @@ -143,16 +150,18 @@ backspace removes its chip but deletes no letter. The word is now plain prose an
sends it to the conversation normally. A second backspace edits the word as usual.

Editing the demoted word makes codeaf recognize its current spelling afresh. Edits before
it merely move the annotation with the text. Emptying or sending the draft forgets all
demotions.
it merely move the annotation with the text. The word stays plain in the sent message
too, including one typed while an answer was still coming that waited its turn, and a
waiting message pulled back into the box keeps it plain there. Emptying the draft forgets
all demotions.

## Slash command did nothing

A command in the middle of a sentence acts only when it is `/standing`, `/orders`, or
`/task`, and a chip is the promise that it will act. `/clear`, `/model`, `/compact` and
the other commands are plain prose there. Put one of those commands at the start if you
want to run it. If a send-door word is plain, it was demoted with backspace; edit it or
type it again to make it live.
`/task`. Every other command there is still highlighted — `/compact`, `/clear` and
`/model` wear the same chip as any recognized word — but enter sends it as ordinary
words. Put one of those commands at the start if you want to run it. If a send-door word
is plain, it was demoted with backspace; edit it or type it again to make it live.

The command list follows that rule when you choose a row from it:

Expand Down
20 changes: 12 additions & 8 deletions internal/manual/chat/screen.md
Original file line number Diff line number Diff line change
Expand Up @@ -915,10 +915,11 @@ What steps up, in the lines you will see it in:
Three rules hold it to one gesture, and they are worth knowing because they tell you what
a mark means:

- **A tinted background on your words is always a slash command that acts** — a command
at the start or a live `/standing`, `/orders`, or `/task` tag later in the draft. Help
rows chip their leading command too. Nothing else borrows the mark, so it never
highlights a slash word the send path will ignore.
- **A tinted background on your words is always a slash command codeaf recognises** — any
recognized command wherever it stands, a leading one or `/compact` mid-sentence. It says
the word is known to codeaf, not that enter will run it: enter acts only on a leading
command, and on a live `/standing`, `/orders`, or `/task` tag later in the draft. Help
rows chip their leading command too. Nothing else borrows the mark.
- **A key chord is brighter ink and never a background.** `ctrl+b`, `esc`, `↑↓` step up a
tier; they do not get a chip.
- **Nothing here is ever drawn in the accent.** The accent marks the one live or chosen
Expand Down Expand Up @@ -3462,8 +3463,8 @@ The thinking window's gradient collapses to flat dim, because a gradient is an a
held still.

What the linear tier **keeps**: the colours (a screen reader ignores them, and a sighted
reader loses nothing), the **chosen row's background** — the model in use, the
conversation you are in, the room you are standing in — and the **copy span's**. Those
reader loses nothing), the **chosen row's background** — the conversation you are in,
the room you are standing in — and the **copy span's**. Those
are facts about the session rather than about a pointer, and they are true whoever is
reading. What is dropped is the quieter background the pointer and the cursor share.

Expand Down Expand Up @@ -4094,13 +4095,16 @@ with its name in bold, the way a row on home does.

The step above that is for the thing you have actually **chosen**, and it stays drawn
when nobody is touching the list: the roster row and the strip chip of the room you are
standing in, the model in use in `/model`, the conversation you are in on home and in
`/resume`, the tab you are on in `/settings`. Both can be
standing in, the conversation you are in on home and in `/resume`, the tab you are on
in `/settings`. Both can be
on screen at once — that is what two steps are for — and the roster is where you will
see it: the room you walked into on the louder ground, the row `↑↓` has reached on the
quieter one. Where a cursor lands on the chosen row itself, the louder ground wins, so a
row never gets quieter for being arrived at, and the `›` still says where enter is aimed.

In `/model`, the model in use keeps bold accent ink with no band of its own; the cursor
or a pointer hovering a row supplies its band.

Nothing else lights: empty space, a paragraph, a dim telemetry line, the hint beside a
picked harness, the body of the task record card, and the phone's tool detail sheet,
which has no pointer targets at all.
Expand Down
34 changes: 16 additions & 18 deletions internal/tui3/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,10 +265,13 @@ type entry struct {
kind entryKind
text string
turn int
// actedTags are send-door words kept in the displayed sentence after they
// were stripped from the payload. Mid-sentence slash prose has no ranges,
// so a demoted tag stays plain in the transcript as promised.
actedTags []segment
// plainTags are the slash words the person demoted with backspace before
// sending, as rune ranges into the displayed message text (entry.text). A
// demoted word stays plain in the transcript exactly as it did in the box:
// [transcriptCommandSpans] subtracts these ranges before painting a chip,
// and an edit before the word carries the range along with it. They are
// empty on every message with no demotion.
plainTags []segment
// replyTags are the finished tasks this assistant block answers. They are
// empty for every ordinary person-prompted reply.
replyTags []session.TaskReplyTag
Expand Down Expand Up @@ -6801,9 +6804,9 @@ func (a *app) submit(text string) tea.Cmd {
return a.submitting(text, submitStart(agent, ctx, text))
}

func (a *app) submitShown(text, shown string) tea.Cmd {
func (a *app) submitShown(text, shown string, plain []segment) tea.Cmd {
agent, ctx := a.agent, a.ctx
return a.submittingShown(text, shown, submitStart(agent, ctx, text))
return a.submittingShown(text, shown, plain, submitStart(agent, ctx, text))
}

// submitStart is the one plain-message engine call used by both front and held
Expand All @@ -6822,14 +6825,17 @@ func submitStart(agent Agent, ctx context.Context, text string) func() (<-chan s
// The second door is a picked harness, which is a turn in every respect except
// which function starts it (harnesspick.go's [app.runPickedHarness]).
func (a *app) submitting(text string, start func() (<-chan session.Event, error)) tea.Cmd {
return a.submittingShown(text, text, start)
return a.submittingShown(text, text, nil, start)
}

// submittingShown separates the words a door receives from the honest line
// the transcript keeps. Slash tags are stripped from the payload but remain in
// the person's message as the chipped token that explains which door acted.
func (a *app) submittingShown(text, shown string, start func() (<-chan session.Event, error)) tea.Cmd {
if a.deferHosted(func() tea.Cmd { return a.submittingShown(text, shown, start) }) {
//
// plain carries the words the person demoted with backspace, as ranges into
// shown, so the transcript leaves them plain (entry.plainTags).
func (a *app) submittingShown(text, shown string, plain []segment, start func() (<-chan session.Event, error)) tea.Cmd {
if a.deferHosted(func() tea.Cmd { return a.submittingShown(text, shown, plain, start) }) {
return nil
}
call := a.hostCallStarted()
Expand Down Expand Up @@ -6859,18 +6865,10 @@ func (a *app) submittingShown(text, shown string, start func() (<-chan session.E
// conversation. It is asked rather than assumed so that the day the engine
// routes a conversation's turn into a named thread, the line that says so is
// already being drawn — one mechanism, keyed off what the session exposes.
var acted []segment
if shown != text {
for _, s := range commandSpans([]rune(shown), true) {
if s.from > 0 {
acted = append(acted, s)
}
}
}
if a.openingPrompt == "" {
a.openingPrompt = shown
}
a.said(entry{kind: entryUser, text: shown, turn: a.turn, actedTags: acted, began: a.now(), context: a.turnContext()})
a.said(entry{kind: entryUser, text: shown, turn: a.turn, plainTags: plain, began: a.now(), context: a.turnContext()})
// AND OVER A CONNECTION THE LINE IS MARKED UNTIL THE ENGINE HAS IT. The
// sentence is already on the page — the line above put it there, in the place
// it will keep — and what a connection adds is a gap between that and the far
Expand Down
6 changes: 3 additions & 3 deletions internal/tui3/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -902,10 +902,10 @@ func userLine(text string, chips []chip, pal palette) string {
// was attached — and a refusal that also lost the person's attachments would
// make them go and find the files again.
func (a *app) submitImages(text string) tea.Cmd {
return a.submitImagesShown(text, text)
return a.submitImagesShown(text, text, nil)
}

func (a *app) submitImagesShown(text, shown string) tea.Cmd {
func (a *app) submitImagesShown(text, shown string, plain []segment) tea.Cmd {
agent, ctx := a.agent, a.ctx
chips := append([]chip(nil), a.chips...)
a.chips, a.sent = nil, chips
Expand All @@ -926,7 +926,7 @@ func (a *app) submitImagesShown(text, shown string) tea.Cmd {
paths := chipPaths(pictures)
a.said(entry{
kind: entryUser, text: userLine(shown, chips, a.pal), turn: a.turn,
context: a.turnContext(), pictures: paths, picturesHere: true,
plainTags: plain, context: a.turnContext(), pictures: paths, picturesHere: true,
})
// AND EACH FILE IS STAT'D HERE, AT ITS ARRIVAL, because the row above is
// about to be drawn with a thumbnail in it and `body` may not ask the disk
Expand Down
1 change: 1 addition & 0 deletions internal/tui3/followup.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ func (a *app) startFollow() tea.Cmd {
// turn late, and where it goes is the same fact about it either way.
a.entries = append(a.entries, entry{
kind: entryUser, text: next.text, turn: a.turn, context: a.turnContext(),
plainTags: restingDoorWords([]rune(next.text)),
})
}
a.state = stateWorking
Expand Down
4 changes: 2 additions & 2 deletions internal/tui3/harnesspick.go
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ type harnessRunner interface {
// with one — and dropping it silently would lose a file somebody went and
// found. It waits in the tray for the next ordinary message, which is the same
// thing a refused submit does with one (attach.go).
func (a *app) runPickedHarness(text string) tea.Cmd {
func (a *app) runPickedHarness(text string, plain []segment) tea.Cmd {
name := a.harnChip
a.harnChip = ""
runner, ok := a.agent.(harnessRunner)
Expand All @@ -606,7 +606,7 @@ func (a *app) runPickedHarness(text string) tea.Cmd {
return nil
}
ctx := a.ctx
return a.submitting(text, func() (<-chan session.Event, error) {
return a.submittingShown(text, text, plain, func() (<-chan session.Event, error) {
// No model is named here. A word chosen in the box travels on the turn's
// own text for the offer lane to read; the picker chose a harness, not a
// model, and inventing one would be the surface answering a question
Expand Down
21 changes: 14 additions & 7 deletions internal/tui3/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -1502,11 +1502,17 @@ func (a *app) enterLine(marked bool) tea.Cmd {
}
var tagDoor sendDoor
var tagWords string
var tagPlain []segment
tagShown := line
// A DEMOTION MUST SURVIVE THE RESET. [editor.reset] nils demotedTags, and
// the transcript is painted long after that, so the ranges are snapshotted
// here and threaded to the entry (app.go's [app.submittingShown]).
plain := a.input.plainTags()
if !strings.HasPrefix(line, "/") && len(tags) == 1 {
tag := tags[0]
tagDoor = commandDoor(string(a.input.value[tag.from+1 : tag.to]))
tagWords = removeSlashTag(a.input.value, tag)
tagPlain = plainWithoutTag(a.input.value, tag, plain)
}
a.input.reset()
a.endRecall()
Expand Down Expand Up @@ -1544,7 +1550,7 @@ func (a *app) enterLine(marked bool) tea.Cmd {
if tagWords == "" {
return a.openStanding()
}
return a.standingSayShown(tagWords, tagShown)
return a.standingSayShown(tagWords, tagShown, plain, tagPlain)
case sendDoorTask:
return a.runTaskCommand(tagWords)
}
Expand All @@ -1555,7 +1561,7 @@ func (a *app) enterLine(marked bool) tea.Cmd {
// the tray's own hint says to type the request, and enter on nothing is the
// no-op it always was.
if a.harnChip != "" {
return a.runPickedHarness(line)
return a.runPickedHarness(line, plain)
}
// EVERY "@task" IN THE SENTENCE GROWS ITS FOOTNOTE HERE, and here is after
// the line has been remembered: what ↑ brings back is what the person typed,
Expand All @@ -1575,18 +1581,19 @@ func (a *app) enterLine(marked bool) tea.Cmd {
// running answer is parked like any other, and it goes through the marked
// door when its turn comes: a mark dropped on the way into the queue would
// be the sentence quietly becoming ordinary work, which is the one ending
// this gesture exists to rule out (park.go).
return a.park(line, marked)
// this gesture exists to rule out (park.go). A tag the person made plain
// waits plain with it, for the same reason.
return a.park(line, marked, plain)
}
shownLine := line
line = a.expandPastes(line)
if held {
return a.submitImagesShown(line, shownLine)
return a.submitImagesShown(line, shownLine, plain)
}
if marked {
return a.submitStandingShown(line, shownLine)
return a.submitStandingShown(line, shownLine, plain)
}
return a.submitShown(line, shownLine)
return a.submitShown(line, shownLine, plain)
}

// completePath is tab: the file list over a command's path argument, opened if
Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@ func (a *app) drawBehindParked(p parked, shown string) {
if len(p.chips) > 0 {
line = userLine(shown, p.chips, a.pal)
}
a.said(entry{kind: entryUser, text: line, turn: a.turn, began: a.now(), context: a.turnContext(), pictures: pictures, picturesHere: len(pictures) > 0})
a.said(entry{kind: entryUser, text: line, turn: a.turn, plainTags: p.plain, began: a.now(), context: a.turnContext(), pictures: pictures, picturesHere: len(pictures) > 0})
for _, picture := range pictures {
a.learnPicture(picture, true)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/tui3/manualcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const (
func (a *app) runManualCommand(rest string) tea.Cmd {
asked := strings.TrimSpace(rest)
if asked == "" {
return a.submitShown(manualTourAsk, "/manual")
return a.submitShown(manualTourAsk, "/manual", nil)
}
return a.submitShown(manualQuestionLead+asked, "/manual "+asked)
return a.submitShown(manualQuestionLead+asked, "/manual "+asked, nil)
}
Loading
Loading