From bb065fe2218254d6902f489aac3c9b4ce669cc02 Mon Sep 17 00:00:00 2001 From: Anachronaut Date: Tue, 1 Sep 2026 17:43:48 -0400 Subject: [PATCH] Tab finishes a word somebody started The first word of a line, against the shell's fifteen commands. One match goes in with a space after it, because a word that can only be one thing is finished. Several are folded into their longest common prefix and that goes in, which is the most that can be said without guessing which was meant - and if that adds nothing, the matches are listed and the line put back underneath. THE LINE COMING BACK IS THE HALF I EXPECTED TO BE HARD and it was already solved. The prompt has been reprinted somewhere else entirely, so the editor's idea of where the line begins is wrong - but editAnchor works that out backwards from where printing ended, precisely so it survives the screen moving. Listing is a redraw it already knew how to do. editInsert became editPut, a routine, because completing a word puts in several characters and every one of them is that. Which cost a bug immediately: the old inline code left the insertion point in A, and a RET puts A back to what the caller had. Two more bugs worth naming, both mine and both the same shape - a pointer that had moved: THE CANDIDATE'S START HAS TO BE KEPT. The comparison walks DP3 through the name as it matches, so by the time a match is declared, DP3 points at the part AFTER what was typed - and that is what got copied. "he" completed to "he" because the answer taken was "lp". AND THE INSERTION STOPS AT OR PAST, not exactly equal. With the wrong answer the two counters passed each other and the loop ran off the end of the buffer, filling the line with whatever was next in memory. They cannot pass each other now, and the branch stays, because the cheaper failure is worth nothing. MY OWN TEST HAD A HOLE and breaking the code found it. The later-word case pressed Tab after a space, where there is nothing to finish anyway, so it passed whether or not the shell checked which word it was on. It types "echo he" now, which would become "echo help" if it did not. The assembler's label table went past 1024 and is doubled. A ceiling reached once will be reached again, and it is pointers into source already in memory. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW --- Programs/CosmOS/README.md | 21 ++ Programs/CosmOS/Source/cosmos.asm | 574 +++++++++++++++++++++++++++--- Source/Assembler/secondPass.h | 7 +- SplitBit Test Manual.md | 2 +- Tests/expected/cosmosTab.out | 34 ++ Tests/input/cosmosTab.in | 8 + Tests/manifest | 14 + 7 files changed, 615 insertions(+), 45 deletions(-) create mode 100644 Tests/expected/cosmosTab.out create mode 100644 Tests/input/cosmosTab.in diff --git a/Programs/CosmOS/README.md b/Programs/CosmOS/README.md index ca4f397..e232bec 100644 --- a/Programs/CosmOS/README.md +++ b/Programs/CosmOS/README.md @@ -299,6 +299,7 @@ line can be moved about in at all. | Backspace | Take out the character before the cursor. | | Delete | Take out the one under it. | | Up, Down | Walk back through the last eight lines, and forward again. | +| Tab | Finish the word being typed, if there is only one thing it could be. | | Return | Finish the line, wherever the cursor happens to be sitting. | Anything typed goes in where the cursor is, so a word left out of the middle of a line is @@ -312,6 +313,26 @@ line, and nowhere for a history to live. Now the console delivers keys and says about what they mean, the same way it reports what a drive is and says nothing about what should be on it, and the shell decides. +### Finishing A Word: + +Tab finishes the **first** word of a line against the shell's own commands. One match goes in +with a space after it, because a word that can only be one thing is finished. Several are +folded into their longest common prefix and that goes in, which is the most that can be said +without guessing which was meant - and if that adds nothing, because what is typed is already +as far as they all agree, the matches are listed and the line put back underneath. + +Nothing typed and nothing matching both do nothing, quietly. Finishing an argument rather +than a command is not built yet. + +**This is the same wall the history was behind.** A shell that never sees a keystroke has no +moment at which somebody has typed half a word - the terminal hands over finished lines - so +there is nothing to press Tab at. What made it possible was moving the line editing into the +system, and everything since has been downstream of that one decision. + +The commands are walkable because they are **packed**: fifteen names, each ending in the zero +that says where the next begins. The dispatch is a chain of comparisons and cannot be walked, +so the names have to be data as well as code, and `make test` checks that the two agree. + ### The History: The last eight lines are kept, and Up walks back through them. It is a ring: a ninth line diff --git a/Programs/CosmOS/Source/cosmos.asm b/Programs/CosmOS/Source/cosmos.asm index 73ab0be..3715b43 100644 --- a/Programs/CosmOS/Source/cosmos.asm +++ b/Programs/CosmOS/Source/cosmos.asm @@ -593,6 +593,9 @@ editNotEnd: INIB 0x81 XOR BRQ editDown + INIB 0x09 + XOR + BRQ tabComplete ; Anything else is a character if it is printable and nothing at all if it is not. The ; console's own keys that this shell has no use for land here and are ignored rather than @@ -608,52 +611,14 @@ editNotEnd: ; ---- Putting a character in ---- editInsert: - SETD.1 EditLength - LDA.1 - SETD.1 EditRoom - LDB.1 - CCF - SUB - BNC editKey ; Full. The zero byte on the end is not counted in the room. + CALL editPut + BNQ editKey ; No room for it. The zero byte on the end is not counted. - ; A hole at the insertion point, made from the top down so nothing is overwritten before - ; it has been moved. - SETD.1 EditLength - LDA.1 - SETD.1 EditAt - LDB.1 - CCF - SUB - MVQB ; How many characters are above the insertion point. - SETD.1 EditBase - LDD.0.1 - SETD.1 EditLength - LDA.1 - DPUA.0 ; One past the last character. -editInsertShift: - BRB editInsertPut - DECD.0 - LDA.0 - INCD.0 - STA.0 - DECD.0 - DECB - BRI editInsertShift -editInsertPut: - SETD.1 EditChar - LDA.1 - STA.0 - - SETD.1 EditLength - LDA.1 - INCA - STA.1 + ; At the end of the line, printing it IS the change. The insertion point is READ BACK + ; rather than still being in A: it was, while this was one run of code, and a RET puts A + ; back to whatever the caller had. SETD.1 EditAt LDA.1 - INCA - STA.1 - - ; At the end of the line, printing it IS the change. SETD.1 EditLength LDB.1 CCF @@ -671,6 +636,70 @@ editInsertRedraw: CALL editRedraw BRI editKey +; Puts the character in EditChar into the line where the insertion point is, and moves the +; insertion point past it. DRAWS NOTHING: what that should look like depends on where in the +; line it went, and the caller is what knows. Q is zero if it went in and one if there was +; no room. +; +; A routine rather than the run of code it used to be, because Tab completing a word puts in +; several characters and every one of them is this. +editPut: + SETD.1 EditLength + LDA.1 + SETD.1 EditRoom + LDB.1 + CCF + SUB + BNC editPutFull + + ; A hole at the insertion point, made from the top down so nothing is overwritten before + ; it has been moved. + SETD.1 EditLength + LDA.1 + SETD.1 EditAt + LDB.1 + CCF + SUB + MVQB ; How many characters are above the insertion point. + SETD.1 EditBase + LDD.0.1 + SETD.1 EditLength + LDA.1 + DPUA.0 ; One past the last character. +editPutShift: + BRB editPutHere + DECD.0 + LDA.0 + INCD.0 + STA.0 + DECD.0 + DECB + BRI editPutShift +editPutHere: + SETD.1 EditChar + LDA.1 + STA.0 + + SETD.1 EditLength + LDA.1 + INCA + STA.1 + SETD.1 EditAt + LDA.1 + INCA + STA.1 + RSTA + RSTB + CCF + ADD ; Q is zero: it went in. + RET +editPutFull: + INIA 0x01 + RSTB + CCF + ADD ; Q is one: it did not. + RET + ; ---- Taking one out ---- ; ; Backspace and Delete are different keys doing different things: one takes the character @@ -1478,6 +1507,433 @@ faultScreen: OUTA 0x02 RET +; ---- Finishing a word somebody started ---- +; +; Tab. What it can finish depends on where in the line it is: the FIRST word is a command or +; the name of a program, and everything after it is a file. Only the first is done here. +; +; None of this could have existed a fortnight ago. The shell never saw a keystroke - the +; terminal handed it a finished line - so there was no moment at which somebody had typed +; half a word and the system knew about it. That is the same wall the history was behind. +; +; ---- What it does with what it finds ---- +; +; One match goes in with a space after it, because a word that can only be one thing is +; finished. Several are folded into their longest common prefix and that goes in, which is +; the most that can be said without guessing. If that adds nothing - because what is typed +; IS already the common prefix - the matches are listed and the line put back underneath. +; +; EVERY HELPER BELOW ANSWERS IN Q. A RET puts A, B and the first three pointers back, so a +; routine here that answered in A would be answering into a register its caller cannot see. +tabComplete: + ; ---- Which word, and is it the first ---- + ; + ; Back from the insertion point to a space or the start of the line. What lies between + ; there and the cursor is what somebody has typed, and what lies before it decides whether + ; this is a command being named or an argument being given. + SETD.1 EditAt + LDA.1 + SETD.1 TabWordAt + STA.1 +tabBack: + SETD.1 TabWordAt + LDA.1 + BRA tabIsFirst ; The start of the line is the start of the word, and of the line. + DECA + CALL tabCharAt + MVQA + INIB 0x20 + XOR + BRQ tabFoundStart ; A space, so the word begins after it. + SETD.1 TabWordAt + LDA.1 + DECA + STA.1 + BRI tabBack + +tabFoundStart: + ; There is a space behind the word, so something may be in front of that space. Only a run + ; of spaces means this is still the first word; anything else is an argument, and finishing + ; one of those is not built yet. + SETD.1 TabWordAt + LDA.1 + SETD.1 TabLeft + STA.1 +tabFirstCheck: + SETD.1 TabLeft + LDA.1 + BRA tabIsFirst + DECA + STA.1 + CALL tabCharAt + MVQA + INIB 0x20 + XOR + BNQ tabDone ; Not a space, so a word came before this one. + BRI tabFirstCheck + +tabIsFirst: + ; How much of it is typed. Nothing at all is not a question anybody asked - it would offer + ; every command there is, which is what help is for. + SETD.1 EditAt + LDA.1 + SETD.1 TabWordAt + LDB.1 + CCF + SUB + BRQ tabDone + MVQA + SETD.1 TabWordLen + STA.1 + + RSTA + SETD.1 TabCount + STA.1 + SETD.1 TabBestLen + STA.1 + + CALL tabTryNames + + SETD.1 TabCount + LDA.1 + BRA tabDone ; Nothing matched, and saying so would be noise. + + ; ---- Putting it in ---- + ; + ; Everything of the answer past what was already typed. The insertion point is at the end + ; of the word, so each character goes in where the one before it left off. + SETD.1 TabWordLen + LDA.1 + SETD.1 TabPut + STA.1 +tabPutNext: + SETD.1 TabPut + LDA.1 + SETD.1 TabBestLen + LDB.1 + CCF + SUB + ; At or past, rather than exactly equal. They cannot pass each other now - a candidate is + ; only offered if it matches what was typed, so the answer is never shorter than that - but + ; they DID while the answer was being taken from the wrong place, and the difference + ; between the two branches was a line that ran off the end and a line that simply did not + ; grow. The cheaper failure is worth a branch that costs nothing. + BNC tabPutDone + SETD.3 TabBest + SETD.1 TabPut + LDA.1 + DPUA.3 + LDA.3 + SETD.1 EditChar + STA.1 + CALL editPut + BNQ tabPutDone ; The line is full, so the rest of the answer will not fit. + SETD.1 TabPut + LDA.1 + INCA + STA.1 + BRI tabPutNext +tabPutDone: + + SETD.1 TabCount + LDA.1 + INIB 0d1 + XOR + BNQ tabAmbiguous + + ; Only one thing it can be, so it is finished and a space says so. + INIA 0x20 + SETD.1 EditChar + STA.1 + CALL editPut + CALL editRedraw + BRI tabDone + +tabAmbiguous: + ; Several. The common prefix has gone in; if it added nothing then what was typed is + ; already as far as they all agree, and the only useful thing left is to show them. + SETD.1 TabBestLen + LDA.1 + SETD.1 TabWordLen + LDB.1 + CCF + SUB + BRQ tabList + CALL editRedraw +tabDone: + BRI editKey + +; A holds an offset into the line. Q is the character there. +tabCharAt: + SETD.1 EditBase + LDD.0.1 + DPUA.0 + LDA.0 + RSTB + CCF + ADD + RET + +; ---- Offering a candidate ---- +; +; DP3 names a string ending in a zero. If it begins with what has been typed it is folded +; into the answer, and DP3 is left just past that zero either way - which is what makes a run +; of packed strings walkable by calling this over and over. +tabOffer: + ; Where this candidate begins, before the comparison below walks DP3 through it. What is + ; taken as the answer is the WHOLE name and not the part after the bit that matched. + SETD.1 TabStart + STD.3.1 + SETD.1 TabWordLen + LDA.1 + SETD.1 TabSeen + STA.1 + RSTA + SETD.1 TabAt + STA.1 +tabOfferMatch: + SETD.1 TabSeen + LDA.1 + BRA tabOfferTake ; Every typed character agreed. + DECA + STA.1 + + LDA.3 + BRA tabOfferSkip ; The candidate ended before what was typed did. + SETD.1 TabHold + STA.1 ; Kept, while the line's character is fetched. + + SETD.1 TabWordAt + LDA.1 + SETD.1 TabAt + LDB.1 + CCF + ADD + MVQA + CALL tabCharAt + MVQA + SETD.1 TabHold + LDB.1 + XOR + BNQ tabOfferSkip + + INCD.3 + SETD.1 TabAt + LDA.1 + INCA + STA.1 + BRI tabOfferMatch + +tabOfferTake: + ; The first match is the answer. Every one after it cuts the answer back to where the two + ; stop agreeing, which is all that can be said without guessing which was meant. + SETD.1 TabCount + LDA.1 + INCA + STA.1 + INIB 0d1 + XOR + BRQ tabOfferFirst + CALL tabNarrow + BRI tabOfferSkip +tabOfferFirst: + CALL tabTake + +tabOfferSkip: + ; Past the end of this candidate, from wherever the comparison stopped. + LDA.3 + BRA tabOfferPast + INCD.3 + BRI tabOfferSkip +tabOfferPast: + INCD.3 + RET + +; The candidate DP3 names becomes the answer. DP3 is put somewhere and read back rather than +; walked, because it is the only pointer that survives a call and the caller still needs it. +tabTake: + SETD.1 TabStart + LDD.0.1 + SETD.2 TabBest + RSTA + SETD.1 TabBestLen + STA.1 +tabTakeNext: + SETD.1 TabBestLen + LDA.1 + INIB 0d63 + CCF + SUB + BNC tabTakeDone ; As much of a name as there is room for. + LDA.0 + BRA tabTakeDone + STA.2 + INCD.0 + INCD.2 + LDA.1 + INCA + STA.1 + BRI tabTakeNext +tabTakeDone: + RET + +; The answer is cut back to where it and the candidate DP3 names stop agreeing. +tabNarrow: + SETD.1 TabStart + LDD.0.1 + SETD.2 TabBest + RSTA + SETD.1 TabNarrowAt + STA.1 +tabNarrowNext: + SETD.1 TabNarrowAt + LDA.1 + SETD.1 TabBestLen + LDB.1 + CCF + SUB + BNC tabNarrowDone ; The answer ran out first, so all of it still agrees. + LDA.0 + BRA tabNarrowCut ; The candidate ran out, so the answer stops here. + LDB.2 + XOR + BNQ tabNarrowCut + INCD.0 + INCD.2 + SETD.1 TabNarrowAt + LDA.1 + INCA + STA.1 + BRI tabNarrowNext +tabNarrowCut: + SETD.1 TabNarrowAt + LDA.1 + SETD.1 TabBestLen + STA.1 +tabNarrowDone: + RET + +; ---- The shell's own words ---- +; +; Fifteen strings packed end to end, each ending in the zero that says where the next one +; begins. That is why they are laid out that way: a chain of comparisons can be executed and +; cannot be walked. +tabTryNames: + SETD.1 ShellNameCount + LDA.1 + SETD.1 TabLeft + STA.1 + SETD.3 ShellNames +tabNameNext: + SETD.1 TabLeft + LDA.1 + BRA tabNamesDone + DECA + STA.1 + CALL tabOffer + BRI tabNameNext +tabNamesDone: + RET + +; ---- Showing them when they cannot be narrowed further ---- +; +; The line is reprinted underneath afterwards, and where it now BEGINS is asked rather than +; remembered: the prompt has just been printed somewhere else entirely, and the whole of the +; editor's idea of where things are hangs off those two registers. +tabList: + CALL newLine + CALL tabListNames + CALL newLine + CALL sayPrompt + + INA 0x03 + SETD.1 EditRow + STA.1 + INA 0x04 + SETD.1 EditColumn + STA.1 + RSTA + SETD.1 EditDrawn + STA.1 ; Nothing of the line is on the screen at its new place. + CALL editRedraw + BRI editKey + +tabListNames: + SETD.1 ShellNameCount + LDA.1 + SETD.1 TabLeft + STA.1 + SETD.3 ShellNames +tabListNext: + SETD.1 TabLeft + LDA.1 + BRA tabListDone + DECA + STA.1 + CALL tabShow + BRI tabListNext +tabListDone: + RET + +; DP3 names a candidate. Prints it if it begins with what was typed, and leaves DP3 past its +; zero either way, the same bargain tabOffer makes. +tabShow: + SETD.1 TabStart + STD.3.1 + SETD.1 TabWordLen + LDA.1 + SETD.1 TabSeen + STA.1 + RSTA + SETD.1 TabAt + STA.1 +tabShowMatch: + SETD.1 TabSeen + LDA.1 + BRA tabShowIt + DECA + STA.1 + LDA.3 + BRA tabShowSkip + SETD.1 TabHold + STA.1 + SETD.1 TabWordAt + LDA.1 + SETD.1 TabAt + LDB.1 + CCF + ADD + MVQA + CALL tabCharAt + MVQA + SETD.1 TabHold + LDB.1 + XOR + BNQ tabShowSkip + INCD.3 + SETD.1 TabAt + LDA.1 + INCA + STA.1 + BRI tabShowMatch + +tabShowIt: + SETD.1 TabStart + LDD.0.1 + CALL printString + INIA 0x20 + OUTA 0x00 + OUTA 0x00 ; Two spaces between them, and A already holds one. + +tabShowSkip: + LDA.3 + BRA tabShowPast + INCD.3 + BRI tabShowSkip +tabShowPast: + INCD.3 + RET + ; ---- A command that did not work ---- ; ; The one place a failure is recorded, so that the thing reading lines out of a file can @@ -5970,6 +6426,38 @@ EditWalkBack: EditWasControl: 0x00 +; ---- What Tab is working on ---- +TabWordAt: + 0x00 +TabWordLen: + 0x00 +TabCount: + 0x00 +TabLeft: + 0x00 +TabSeen: + 0x00 +TabAt: + 0x00 +TabHold: + 0x00 +TabPut: + 0x00 +TabNarrowAt: + 0x00 +TabBestLen: + 0x00 +; Where a candidate is, put down so that a routine which needs to walk it can pick it up +; without moving the pointer its caller is still using. +TabPointer: + 0x00 0x00 +; And where the candidate being looked at begins, since the comparison walks past it. +TabStart: + 0x00 0x00 +; The answer so far: the first match, cut back by every one after it to where they agree. +TabBest: + #Reserve 0d64 + ; ---- What the fault screen is saying ---- ; ; Whether this cause names an entry as well as itself, and which one. Only the two faults diff --git a/Source/Assembler/secondPass.h b/Source/Assembler/secondPass.h index f689349..f2a2d5c 100644 --- a/Source/Assembler/secondPass.h +++ b/Source/Assembler/secondPass.h @@ -15,7 +15,12 @@ // share one namespace: a name may only be defined once across the whole assembly. So this // is not the size of one file but the size of a program and its libraries together, and // CosmOS with its four libraries went past 256 while still being a small system. -#define MAX_LABELS 1024 +// +// AND PAST 1024 the day the shell learned to finish a word somebody had started. Doubled +// rather than nudged: a ceiling reached once is a ceiling that will be reached again, and +// the table is pointers into source that is already in memory - 2048 of them is sixteen +// kilobytes on a host with gigabytes of it. +#define MAX_LABELS 2048 #define MAX_VECTORS 256 typedef struct { diff --git a/SplitBit Test Manual.md b/SplitBit Test Manual.md index e6f5646..b2dea2f 100644 --- a/SplitBit Test Manual.md +++ b/SplitBit Test Manual.md @@ -79,7 +79,7 @@ from `make`, not from here. ### 1. Recorded output `Tests/run.sh` assembles each program named in `Tests/manifest`, runs it, and compares -everything it printed against a file in `Tests/expected`. 197 tests, of which 135 run, 35 +everything it printed against a file in `Tests/expected`. 198 tests, of which 136 run, 35 only assemble, 16 are expected to fail to assemble, and 11 boot from ROM with no image given at all. diff --git a/Tests/expected/cosmosTab.out b/Tests/expected/cosmosTab.out new file mode 100644 index 0000000..33a2965 --- /dev/null +++ b/Tests/expected/cosmosTab.out @@ -0,0 +1,34 @@ +CosmOS +> +> ececho one +one +> mkmkdir /made-by-tab +made +> d +dir do drive delete +> d +I do not know: d +> zzzx +I do not know: zzzx +> echo here +here +> help  help  +dir list what is on the disk +load read a program off the disk +run [words] start what was loaded, and tell it those words + [words] look where you are and then in /Apps, and start that +cd [path] go to a directory, or to the root with nothing after it +mkdir make a directory +rmdir remove an empty one +do run the lines in a file, which must start with #! +echo [words] say them +clear empty the screen +delete take it off the disk +rename call it something else +monitor look at memory, change it, and jump into it +help this +exit stop, or leave the monitor if you are in it +> exit +halted +Execution halted. +[exit 0] diff --git a/Tests/input/cosmosTab.in b/Tests/input/cosmosTab.in new file mode 100644 index 0000000..4308489 --- /dev/null +++ b/Tests/input/cosmosTab.in @@ -0,0 +1,8 @@ + +ec one +mk /made-by-tab +d +zzz x +echo he re +help +exit diff --git a/Tests/manifest b/Tests/manifest index 462f18c..dff4fcc 100644 --- a/Tests/manifest +++ b/Tests/manifest @@ -375,6 +375,20 @@ cosmosEditKeys | CosmOS/Source/cosmos.asm | run | cosmosEdi # the tail of the long one on the screen unless something covers it, and one space - which # is all a Delete ever needs - covers nothing. cosmosHistory | CosmOS/Source/cosmos.asm | run | cosmosHistory.in | - | disks/cosmos.img +# Tab finishing a word somebody started, which is a thing an eight bit shell could not do +# for a structural reason rather than a hard one: it never saw the keystroke, so there was no +# moment at which half a word existed and the system knew. +# +# Every case is here. One match goes in with a space after it. Four that agree on nothing +# more are listed and the line put back underneath - and the line coming back is the half +# worth watching, because the prompt has been reprinted somewhere else and the editor's idea +# of where the line starts has to be asked again rather than remembered. Nothing typed and +# nothing matching both do nothing quietly, and a second Tab later in the line does nothing +# because finishing an argument is not built yet. +# +# The last line is the one that says it works on what is actually there rather than on what +# was typed: two backspaces take "help" down to "he", and Tab finishes it again. +cosmosTab | CosmOS/Source/cosmos.asm | run | cosmosTab.in | - | disks/cosmos.img # The same editing offered to a PROGRAM, through osReadLine. Edit reads its lines that way, # so a word typed with two letters the wrong way round is put right without starting the line # again - which is the whole of what A4 buys.